Compare commits

...

4 commits

114 changed files with 14270 additions and 2277 deletions

329
CLAUDE.md Normal file
View file

@ -0,0 +1,329 @@
## 2. Member Features
### Member Profiles
**Core Fields:**
- Name, pronouns, time zone
- Avatar/photo - choose from ghosts
- Studio/organization affiliation
- Bio (rich text)
- Skills tags (searchable)
- Location (city/region)
- Social links (Mastodon, LinkedIn, etc.)
- **Offering:** What I can contribute
- **Looking For:** What I need support with
**Privacy Controls:**
- Public/members-only/private toggle per field
- Opt-in to member directory
### Member Updates/Mini Blog
- Post updates about projects, learnings, questions
- Rich text with image support
- Comments enabled
- Filter by circle or topic tags
## 3. Events System
### Core Features
- RSVP with capacity limits
- Waitlist management
- Add to calendar (.ics download)
- Pre-event discussion threads
- Post-event recordings archive
- Speaker/facilitator profiles
### Member-Proposed Events
**Proposal Flow:**
1. Member submits event idea via form
2. Include: Topic, format, target circle, time commitment
3. Admin quick review (spam check only)
4. Published to "Proposed Events" board
5. Members can express interest (like feature upvote pages)
6. If threshold met (e.g., 5 interested), event is scheduled
7. Proposer gets facilitator support if needed
## 4. Resources Integration
### Consolidating Existing Assets
**Import and organize from:**
- learn.weirdghosts.ca content
- Existing tools and templates
- PA curriculum materials (where appropriate)
- Case studies and examples
**Organization Structure:**
```
Resources/
├── Start Here/
│ ├── Welcome Letter from Jennie & Eileen
│ ├── How Ghost Guild Works
│ └── Solidarity Economics Explained
├── Learning Paths/
│ ├── Community Track → links to learn.weirdghosts.ca
│ ├── Founder Track → practical tools
│ └── Practitioner Track → advanced resources
├── Templates & Tools/
│ ├── Governance/
│ ├── Financial/
│ ├── Operations/
│ └── Legal/
├── Case Studies/
│ └── Member stories and examples
└── External Resources/
└── Curated links and recommendations
```
### Resource Features
- Tag by circle relevance (but accessible to all)
- Download tracking for impact metrics
- Version control for templates
- Comment threads on resources
- "Request a resource" feature
## 5. Peer Support System
### Cal.com Integration for 1:1s
**Setup:**
- Each member can enable peer support availability
- Set their own hours/frequency
- Cal.com handles scheduling
- Types of sessions:
- Peer support (30 min)
- Co-founder check-in (45 min)
- Practitioner office hours (60 min)
**Matching System:**
- Simple questionnaire about current needs
- Suggest 3 potential peers based on:
- Complementary skills/needs
- Time zone compatibility
- Circle alignment (optional)
- Book directly via Cal.com links
## 6. Dashboard Design
### Personalized Sections
**Welcome Block:**
- "Welcome back, [Name]"
- Your circle: [Circle] | Your contribution: $X/month
- Quick stats: Days as member, events attended, peers met
**Community Pulse:**
- Recent member updates (mini blog posts)
- Upcoming events this week
- New resources added
- New members to welcome
**Your Activity:**
- Your upcoming events
- Scheduled peer sessions
- Recent discussions you're in
- Resources you've bookmarked
**Take Action:**
- Post an update
- Propose an event
- Book a peer session
- Browse resources
- Update profile
**Impact Metrics:**
- Total solidarity spots funded
- Events hosted this month
- Active members this week
- Resources shared
## 7. Collaborative Tools
### Etherpad Integration
**Use Cases:**
- Meeting notes templates
- Collaborative resource creation
- Event planning documents
- Shared learning notes
**Implementation:**
- Self-hosted Etherpad instance
- SSO with Ghost Guild accounts
- Auto-save and version history
- Export to multiple formats
- Embed in event pages for notes
### Living Documents
- Community-maintained guides
- Glossaries and definitions
- Frequently asked questions
- Best practices collections
## 8. Technical Infrastructure
### Notification System
**Channels:**
- Email (via Resend)
- In-app notifications
- Slack integration via bot
**Configurable Preferences:**
- Event reminders
- New resources in your area
- Peer session invitations
- Member updates digest
- Community announcements
### Search & Discovery
- Full-text search across:
- Resources
- Member profiles
- Event descriptions
- Member updates
- Filter by circle, tags, date
- Save searches for alerts
### Analytics & Reporting
- Member engagement metrics
- Resource usage stats
- Event attendance patterns
- Contribution distribution
- Circle movement tracking
## 9. Content for Launch
### Essential Content Pieces
1. **Welcome Video** - Jennie & Eileen introduce Ghost Guild
2. **How This Works** - Clear explanation of circles and contributions
3. **Circle Guides** - What to expect in each circle
4. **Solidarity Economics** - Practical examples from gaming
5. **Getting Started Checklist** - First week actions
### Pre-Populated Content
- 10-15 essential resources per circle
- 3-5 upcoming events scheduled
- Sample member updates to show activity
- FAQ based on pre-registration questions
## 10. Launch Strategy
### Soft Launch (Week Before)
- Invite 10-15 friendly testers
- Each from different backgrounds/circles
- Gather feedback on:
- Onboarding flow
- Resource organization
- Event system
- Profile creation
### Launch Week
**Day 1-2:** PA alumni and close network
- Personal invitations
- Extra support available
- Gather testimonials
**Day 3-4:** Gamma Space announcement
- Post in relevant channels
- Host info session
**Day 5-7:** Public launch
- Email pre-registration list
- Social media announcement
- Open registration
### Success Metrics
**Week 1:**
- 30 members across all circles
- 80% complete profiles
- 50% attend first event
**Month 1:**
- 75 active members
- 5 member-proposed events
- 20 peer sessions booked
- 90% Slack participation
## 11. Ongoing Operations
### Weekly Tasks
- Review member proposals for events
- Process Gamma Space channel access
- Update resource library
- Send member spotlight
### Monthly Tasks
- Impact report to members
- Review and adjust contribution distribution
- Plan next month's events
- Gather member feedback
### Quarterly Reviews
- Assess circle definitions
- Evaluate pricing model
- Review platform features
- Plan new initiatives
---
## Implementation Priority Order
### Must Have for Launch
1. Payment processing (Helcim)
2. Basic Slack automation
3. Member dashboard
4. Simple resource library
5. Event listing and RSVP
### Nice to Have for Launch
7. Member profiles
8. Peer matching system
9. Cal.com integration
10. Member updates/blog
### Can Build Post-Launch
11. Etherpad integration
12. Member-proposed events
13. Advanced search
14. Analytics dashboard
15. Monthly themes

120
HELCIM_TEST_INSTRUCTIONS.md Normal file
View file

@ -0,0 +1,120 @@
# Helcim Integration Testing Guide
## Setup Complete
The Helcim Recurring API integration has been set up with the following components:
### 1. Composables
- `/app/composables/useHelcim.js` - Server-side Helcim API interactions
- `/app/composables/useHelcimPay.js` - Client-side HelcimPay.js integration
### 2. Server API Endpoints
- `/server/api/helcim/customer.post.js` - Creates Helcim customer and member record
- `/server/api/helcim/subscription.post.js` - Creates subscription for paid tiers
- `/server/api/helcim/verify-payment.post.js` - Verifies payment token
### 3. Updated Pages
- `/app/pages/join.vue` - Multi-step signup flow with payment integration
### 4. Database Schema
- Updated `/server/models/member.js` with subscription fields
## Testing Instructions
### Prerequisites
1. Ensure your `.env` file has the test Helcim token:
```
NUXT_PUBLIC_HELCIM_TOKEN=your_test_token_here
```
2. Ensure you have test payment plans created in Helcim dashboard matching these IDs:
- `supporter-monthly-5`
- `member-monthly-15`
- `advocate-monthly-30`
- `champion-monthly-50`
### Test Flow
#### 1. Start the Development Server
```bash
npm run dev
```
#### 2. Test Free Tier Signup
1. Navigate to `/join`
2. Fill in name and email
3. Select any circle
4. Choose "$0 - I need support right now"
5. Click "Complete Registration"
6. Should go directly to confirmation without payment
#### 3. Test Paid Tier Signup
1. Navigate to `/join`
2. Fill in test details:
- Name: Test User
- Email: test@example.com
3. Select any circle
4. Choose a paid contribution tier (e.g., "$15 - I can sustain the community")
5. Click "Continue to Payment"
6. On the payment step, use Helcim test card numbers:
- **Success**: 4111 1111 1111 1111
- **Decline**: 4000 0000 0000 0002
- CVV: Any 3 digits
- Expiry: Any future date
7. Click "Complete Payment"
8. Should see confirmation with member details
### Test Card Numbers (Helcim Test Mode)
- **Visa Success**: 4111 1111 1111 1111
- **Mastercard Success**: 5500 0000 0000 0004
- **Amex Success**: 3400 0000 0000 009
- **Decline**: 4000 0000 0000 0002
- **Insufficient Funds**: 4000 0000 0000 0051
### Debugging
#### Check API Responses
Open browser DevTools Network tab to monitor:
- `/api/helcim/customer` - Should return customer ID and token
- `/api/helcim/verify-payment` - Should return card details
- `/api/helcim/subscription` - Should return subscription ID
#### Common Issues
1. **HelcimPay.js not loading**
- Check console for script loading errors
- Verify token is correctly set in environment
2. **Customer creation fails**
- Check API token permissions in Helcim dashboard
- Verify MongoDB connection
3. **Payment verification fails**
- Ensure you're using test card numbers
- Check that Helcim account is in test mode
4. **Subscription creation fails**
- Verify payment plan IDs exist in Helcim
- Check that card token was successfully captured
### Database Verification
Check MongoDB for created records:
```javascript
// In MongoDB shell or client
db.members.findOne({ email: "test@example.com" })
```
Should see:
- `helcimCustomerId` populated
- `helcimSubscriptionId` for paid tiers
- `status: "active"` after successful payment
- `paymentMethod: "card"` for paid tiers
## Next Steps
Once testing is successful:
1. Switch to production Helcim token
2. Create production payment plans in Helcim
3. Update plan IDs in `/app/config/contributions.js` if needed
4. Test with real payment card (small amount)
5. Set up webhook endpoints for subscription events (renewals, failures, cancellations)

57
UPDATE_SUMMARY.md Normal file
View file

@ -0,0 +1,57 @@
# Helcim Integration - Issues Fixed
## Problem
The API was returning 401 Unauthorized when trying to create customers.
## Root Cause
The runtime config wasn't properly accessing the Helcim token in server-side endpoints.
## Solution Applied
### 1. Fixed Runtime Config Access
Updated all server endpoints to:
- Pass the `event` parameter to `useRuntimeConfig(event)`
- Fallback to `process.env.NUXT_PUBLIC_HELCIM_TOKEN` if config doesn't load
### 2. Files Updated
- `/server/api/helcim/customer.post.js`
- `/server/api/helcim/subscription.post.js`
- `/server/api/helcim/verify-payment.post.js`
- `/server/api/helcim/test-connection.get.js`
### 3. Fixed Import Path
Created `/server/config/contributions.js` to re-export the contributions config for server-side imports.
### 4. Verified Token Works
Created `test-helcim-direct.js` which successfully:
- Connected to Helcim API
- Created a test customer (ID: 32854583, Code: CST1000)
## Testing Instructions
1. Restart your development server:
```bash
npm run dev
```
2. Test the connection:
```bash
curl http://localhost:3000/api/helcim/test-connection
```
3. Try the signup flow at `/join`
## Important Notes
- The token in your `.env` file is working correctly
- The Helcim API is accessible and responding
- Customer creation is functional when called directly
- The issue was specifically with how Nuxt's runtime config was being accessed in server endpoints
## Next Steps
Once you confirm the signup flow works:
1. Test with different contribution tiers
2. Verify payment capture with test cards
3. Check that subscriptions are created correctly
4. Consider adding webhook endpoints for subscription events

View file

@ -1,8 +1,13 @@
export default defineAppConfig({ export default defineAppConfig({
ui: { ui: {
colors: { colors: {
primary: "pink", primary: "emerald",
neutral: "zinc", neutral: "stone",
},
formField: {
slots: {
label: "block font-medium text-stone-200",
},
}, },
}, },
}); });

View file

@ -2,13 +2,145 @@
@import "tailwindcss"; @import "tailwindcss";
@import "@nuxt/ui"; @import "@nuxt/ui";
@theme { @theme static {
/* Font families */ /* Font families */
--font-sans: "Inter", sans-serif; --font-sans: "Inter", sans-serif;
--font-body: "Inter", sans-serif; --font-body: "Inter", sans-serif;
--font-mono: "Ubuntu Mono", monospace; --font-mono: "Ubuntu Mono", monospace;
--font-display: "NB Television Pro", monospace; --font-display: "NB Television Pro", monospace;
/* Ethereal color palette - grays, blacks, minimal color */
--color-ghost-50: #f0f0f0;
--color-ghost-100: #d0d0d0;
--color-ghost-200: #b0b0b0;
--color-ghost-300: #8a8a8a;
--color-ghost-400: #6a6a6a;
--color-ghost-500: #4a4a4a;
--color-ghost-600: #3a3a3a;
--color-ghost-700: #2a2a2a;
--color-ghost-800: #1a1a1a;
--color-ghost-900: #0a0a0a;
/* Subtle accent - barely visible blue-gray */
--color-whisper-50: #d4dae6;
--color-whisper-100: #a8b3c7;
--color-whisper-200: #8491a8;
--color-whisper-300: #687291;
--color-whisper-400: #4f5d7a;
--color-whisper-500: #3a4964;
--color-whisper-600: #2f3b52;
--color-whisper-700: #252d40;
--color-whisper-800: #1a1f2e;
--color-whisper-900: #0f1419;
/* Sparkle accent */
--color-sparkle-50: #fafafa;
--color-sparkle-100: #f0f0f0;
--color-sparkle-200: #e8e8e8;
--color-sparkle-300: #d0d0d0;
--color-sparkle-400: #c0c0c0;
--color-sparkle-500: #a0a0a0;
--color-sparkle-600: #808080;
--color-sparkle-700: #606060;
--color-sparkle-800: #404040;
--color-sparkle-900: #202020;
}
/* Global ethereal background */
:root {
--ethereal-bg: radial-gradient(circle at 20% 80%, rgba(232, 232, 232, 0.03) 0%, transparent 50%),
radial-gradient(circle at 80% 20%, rgba(232, 232, 232, 0.02) 0%, transparent 50%),
radial-gradient(circle at 40% 40%, rgba(232, 232, 232, 0.01) 0%, transparent 50%);
--halftone-pattern: radial-gradient(circle, rgba(255,255,255,0.1) 1px, transparent 1px);
--halftone-size: 8px 8px;
}
html {
background: var(--color-ghost-900);
color: var(--color-ghost-200);
}
body {
background: var(--ethereal-bg), var(--color-ghost-900);
background-attachment: fixed;
}
/* Halftone texture overlay */
.halftone-texture {
position: relative;
}
.halftone-texture::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--halftone-pattern);
background-size: var(--halftone-size);
opacity: 0.1;
pointer-events: none;
}
/* Sparkle effects */
@keyframes sparkle {
0%, 100% { opacity: 0.3; transform: scale(0.8); }
50% { opacity: 1; transform: scale(1.2); }
}
@keyframes twinkle {
0%, 100% { opacity: 0.2; }
25% { opacity: 0.8; }
75% { opacity: 0.4; }
}
.sparkle-field {
position: relative;
overflow: hidden;
}
.sparkle-field::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
radial-gradient(circle at 10% 20%, var(--color-sparkle-200) 1px, transparent 1px),
radial-gradient(circle at 90% 80%, var(--color-sparkle-400) 1px, transparent 1px),
radial-gradient(circle at 30% 70%, var(--color-sparkle-200) 0.5px, transparent 0.5px),
radial-gradient(circle at 70% 30%, var(--color-sparkle-400) 0.5px, transparent 0.5px),
radial-gradient(circle at 50% 10%, var(--color-sparkle-200) 1px, transparent 1px),
radial-gradient(circle at 20% 90%, var(--color-sparkle-400) 0.5px, transparent 0.5px);
background-size: 200px 200px, 300px 300px, 150px 150px, 250px 250px, 180px 180px, 220px 220px;
animation: twinkle 4s infinite ease-in-out;
pointer-events: none;
opacity: 0.6;
}
/* Ethereal glow effects */
.ethereal-glow {
box-shadow:
0 0 20px rgba(232, 232, 232, 0.1),
0 0 40px rgba(232, 232, 232, 0.05),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
.ethereal-text {
text-shadow: 0 0 10px rgba(232, 232, 232, 0.3);
}
/* Dithered gradients */
.dithered-bg {
background:
linear-gradient(45deg, var(--color-ghost-800) 25%, transparent 25%),
linear-gradient(-45deg, var(--color-ghost-800) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, var(--color-ghost-700) 75%),
linear-gradient(-45deg, transparent 75%, var(--color-ghost-700) 75%);
background-size: 4px 4px;
background-position: 0 0, 0 2px, 2px -2px, -2px 0px;
} }

View file

@ -1,288 +1,31 @@
<template> <template>
<footer <footer class="mt-32 pb-16 px-8 md:px-12 lg:px-16">
class="py-16 border-t" <!-- Minimal footer content -->
:class="[ <div class="max-w-4xl">
backgroundClass, <div
borderClass class="flex flex-col md:flex-row justify-between items-start md:items-end gap-8"
]" >
> <!-- Left: Copyright and minimal info -->
<UContainer> <div>
<!-- Main Footer Content --> <p class="text-stone-500 text-xs mb-2">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8 mb-12"> © {{ currentYear }} Ghost Guild
<!-- Brand Section -->
<div class="lg:col-span-1">
<div class="flex items-center gap-2 mb-4">
<div class="w-8 h-8 rounded-full flex items-center justify-center" :class="logoBackgroundClass">
<div class="w-4 h-4 bg-white rounded-sm" />
</div>
<div class="w-6 h-6" :class="logoBackgroundClass" style="clip-path: polygon(50% 0%, 0% 100%, 100% 100%)" />
<span class="text-2xl font-bold ml-2" :class="brandTextClass">{{ brandName }}</span>
</div>
<p :class="textColorClass" class="text-sm leading-relaxed">
{{ description }}
</p> </p>
</div> </div>
<!-- Navigation Links --> <!-- Right: Contact links -->
<div class="lg:col-span-1"> <div class="flex flex-wrap gap-6 text-xs">
<h3 class="font-semibold mb-4" :class="headingColorClass">Navigation</h3> <a
<ul class="space-y-2"> href="mailto:hello@ghostguild.org"
<li v-for="link in navigationLinks" :key="link.path"> class="text-stone-500 hover:text-stone-300 transition-colors"
<NuxtLink >
:to="link.path" Contact
:class="linkColorClass" </a>
class="text-sm hover:underline transition-colors"
>
{{ link.label }}
</NuxtLink>
</li>
</ul>
</div>
<!-- Community Links -->
<div class="lg:col-span-1">
<h3 class="font-semibold mb-4" :class="headingColorClass">Community</h3>
<ul class="space-y-2">
<li v-for="link in communityLinks" :key="link.path">
<NuxtLink
:to="link.path"
:class="linkColorClass"
class="text-sm hover:underline transition-colors"
>
{{ link.label }}
</NuxtLink>
</li>
</ul>
</div>
<!-- Contact/Social -->
<div class="lg:col-span-1">
<h3 class="font-semibold mb-4" :class="headingColorClass">Connect</h3>
<ul class="space-y-2">
<li v-for="link in socialLinks" :key="link.href">
<a
:href="link.href"
:class="linkColorClass"
class="text-sm hover:underline transition-colors"
target="_blank"
rel="noopener noreferrer"
>
{{ link.label }}
</a>
</li>
</ul>
</div> </div>
</div> </div>
</div>
<!-- Decorative Elements (matching wireframe) -->
<div class="flex items-center justify-between mb-8">
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-full flex items-center justify-center" :class="logoBackgroundClass">
<div class="w-4 h-4 bg-white rounded-sm" />
</div>
<div class="w-6 h-6" :class="logoBackgroundClass" style="clip-path: polygon(50% 0%, 0% 100%, 100% 100%)" />
</div>
<div class="hidden md:flex items-center gap-8">
<div class="space-y-2">
<div class="h-1 w-16 rounded-full" :class="decorativeBarClass" />
<div class="h-1 w-12 rounded-full" :class="decorativeBarSecondaryClass" />
<div class="h-1 w-14 rounded-full" :class="decorativeBarTertiaryClass" />
</div>
<div class="space-y-2">
<div class="h-1 w-12 rounded-full" :class="decorativeBarClass" />
<div class="h-1 w-16 rounded-full" :class="decorativeBarSecondaryClass" />
<div class="h-1 w-10 rounded-full" :class="decorativeBarTertiaryClass" />
</div>
<div class="space-y-2">
<div class="h-1 w-14 rounded-full" :class="decorativeBarClass" />
<div class="h-1 w-10 rounded-full" :class="decorativeBarSecondaryClass" />
<div class="h-1 w-16 rounded-full" :class="decorativeBarTertiaryClass" />
</div>
</div>
</div>
<!-- Copyright -->
<div class="pt-8 text-center" :class="borderClass">
<p :class="textColorClass" class="text-sm">
© {{ currentYear }} {{ brandName }}. {{ copyrightText }}
</p>
</div>
</UContainer>
</footer> </footer>
</template> </template>
<script setup> <script setup>
import { computed } from 'vue' const currentYear = new Date().getFullYear();
</script>
const props = defineProps({
theme: {
type: String,
default: 'purple',
validator: (value) => ['purple', 'blue', 'emerald', 'gray'].includes(value)
},
brandName: {
type: String,
default: 'Ghost Guild'
},
description: {
type: String,
default: 'A community for game developers exploring cooperative models and building sustainable studios together.'
},
copyrightText: {
type: String,
default: 'All rights reserved.'
},
customNavigationLinks: {
type: Array,
default: () => []
},
customCommunityLinks: {
type: Array,
default: () => []
},
customSocialLinks: {
type: Array,
default: () => []
}
})
const currentYear = new Date().getFullYear()
const navigationLinks = computed(() => {
if (props.customNavigationLinks.length > 0) {
return props.customNavigationLinks
}
return [
{ label: 'Home', path: '/' },
{ label: 'About', path: '/about' },
{ label: 'Events', path: '/events' },
{ label: 'Join', path: '/join' },
{ label: 'Contact', path: '/contact' }
]
})
const communityLinks = computed(() => {
if (props.customCommunityLinks.length > 0) {
return props.customCommunityLinks
}
return [
{ label: 'Upcoming Events', path: '/events' },
{ label: 'Past Events', path: '/events/past' },
{ label: 'Event Calendar', path: '/events/calendar' },
{ label: 'Members Directory', path: '/members' }
]
})
const socialLinks = computed(() => {
if (props.customSocialLinks.length > 0) {
return props.customSocialLinks
}
return [
{ label: 'Discord Community', href: 'https://discord.gg/ghostguild' },
{ label: 'Twitter', href: 'https://twitter.com/ghostguild' },
{ label: 'GitHub', href: 'https://github.com/ghostguild' },
{ label: 'Contact Us', href: 'mailto:hello@ghostguild.org' }
]
})
const backgroundClass = computed(() => {
const themes = {
purple: 'bg-purple-50 dark:bg-purple-900/20',
blue: 'bg-blue-50 dark:bg-blue-900/20',
emerald: 'bg-emerald-50 dark:bg-emerald-900/20',
gray: 'bg-gray-50 dark:bg-gray-900'
}
return themes[props.theme] || themes.purple
})
const borderClass = computed(() => {
const themes = {
purple: 'border-purple-200 dark:border-purple-800',
blue: 'border-blue-200 dark:border-blue-800',
emerald: 'border-emerald-200 dark:border-emerald-800',
gray: 'border-gray-200 dark:border-gray-700'
}
return themes[props.theme] || themes.purple
})
const logoBackgroundClass = computed(() => {
const themes = {
purple: 'bg-purple-500',
blue: 'bg-blue-500',
emerald: 'bg-emerald-500',
gray: 'bg-gray-500'
}
return themes[props.theme] || themes.purple
})
const brandTextClass = computed(() => {
const themes = {
purple: 'text-purple-600 dark:text-purple-400',
blue: 'text-blue-600 dark:text-blue-400',
emerald: 'text-emerald-600 dark:text-emerald-400',
gray: 'text-gray-900 dark:text-white'
}
return themes[props.theme] || themes.purple
})
const headingColorClass = computed(() => {
const themes = {
purple: 'text-purple-900 dark:text-purple-100',
blue: 'text-blue-900 dark:text-blue-100',
emerald: 'text-emerald-900 dark:text-emerald-100',
gray: 'text-gray-900 dark:text-white'
}
return themes[props.theme] || themes.purple
})
const textColorClass = computed(() => {
const themes = {
purple: 'text-purple-600 dark:text-purple-400',
blue: 'text-blue-600 dark:text-blue-400',
emerald: 'text-emerald-600 dark:text-emerald-400',
gray: 'text-gray-600 dark:text-gray-400'
}
return themes[props.theme] || themes.purple
})
const linkColorClass = computed(() => {
const themes = {
purple: 'text-purple-700 dark:text-purple-300 hover:text-purple-900 dark:hover:text-purple-100',
blue: 'text-blue-700 dark:text-blue-300 hover:text-blue-900 dark:hover:text-blue-100',
emerald: 'text-emerald-700 dark:text-emerald-300 hover:text-emerald-900 dark:hover:text-emerald-100',
gray: 'text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100'
}
return themes[props.theme] || themes.purple
})
const decorativeBarClass = computed(() => {
const themes = {
purple: 'bg-purple-500',
blue: 'bg-blue-500',
emerald: 'bg-emerald-500',
gray: 'bg-gray-500'
}
return themes[props.theme] || themes.purple
})
const decorativeBarSecondaryClass = computed(() => {
const themes = {
purple: 'bg-purple-400',
blue: 'bg-blue-400',
emerald: 'bg-emerald-400',
gray: 'bg-gray-400'
}
return themes[props.theme] || themes.purple
})
const decorativeBarTertiaryClass = computed(() => {
const themes = {
purple: 'bg-purple-300',
blue: 'bg-blue-300',
emerald: 'bg-emerald-300',
gray: 'bg-gray-300'
}
return themes[props.theme] || themes.purple
})
</script>

View file

@ -1,102 +1,191 @@
<template> <template>
<nav class="border-b border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900"> <nav
<UContainer> class="w-64 lg:w-80 backdrop-blur-sm h-screen sticky top-0 flex flex-col"
<div class="flex items-center justify-between py-4"> >
<!-- Logo/Brand --> <!-- Logo/Brand at top -->
<NuxtLink to="/" class="flex items-center gap-2"> <div class="p-8 border-b border-ghost-800 bg-blue-400">
<div class="w-8 h-8 bg-emerald-500 rounded-full flex items-center justify-center"> <NuxtLink to="/" class="flex flex-col items-center gap-3 group">
<div class="w-4 h-4 bg-white rounded-sm" /> <span
</div> class="text-xl font-bold text-stone-100 ethereal-text tracking-wider"
<div class="w-6 h-6 bg-emerald-500" style="clip-path: polygon(50% 0%, 0% 100%, 100% 100%)" /> >Ghost Guild Logo</span
<span class="text-xl font-bold text-gray-900 dark:text-white ml-2">Ghost Guild</span>
</NuxtLink>
<!-- Desktop Navigation -->
<div class="hidden md:flex items-center gap-8">
<NuxtLink
v-for="item in navigationItems"
:key="item.path"
:to="item.path"
class="text-gray-600 dark:text-gray-300 hover:text-emerald-600 dark:hover:text-emerald-400 transition-colors font-medium"
active-class="text-emerald-600 dark:text-emerald-400"
>
{{ item.label }}
</NuxtLink>
<UButton
to="/login"
variant="outline"
size="sm"
class="ml-4"
>
Login
</UButton>
</div>
<!-- Mobile Menu Button -->
<button
class="md:hidden p-2"
@click="toggleMobileMenu"
aria-label="Toggle menu"
> >
<div class="space-y-1"> </NuxtLink>
<div class="h-0.5 w-6 bg-gray-600 dark:bg-gray-300 transition-all" :class="{ 'rotate-45 translate-y-1.5': mobileMenuOpen }" /> </div>
<div class="h-0.5 w-6 bg-gray-600 dark:bg-gray-300 transition-all" :class="{ 'opacity-0': mobileMenuOpen }" />
<div class="h-0.5 w-6 bg-gray-600 dark:bg-gray-300 transition-all" :class="{ '-rotate-45 -translate-y-1.5': mobileMenuOpen }" /> <!-- Vertical Navigation -->
</div> <div class="flex-1 p-8 overflow-y-auto">
</button> <ul class="space-y-6">
</div> <li v-for="item in navigationItems" :key="item.path">
<NuxtLink :to="item.path" class="block group relative">
<!-- Mobile Navigation --> <!-- Hover indicator -->
<div
v-if="mobileMenuOpen" <span
class="md:hidden py-4 border-t border-gray-200 dark:border-gray-700" class="text-stone-200 hover:text-stone-100 transition-all duration-300 text-lg tracking-wide block py-2 hover:ethereal-text"
> active-class="text-stone-100 ethereal-text translate-x-2"
<div class="flex flex-col space-y-3"> >
<NuxtLink {{ item.label }}
v-for="item in navigationItems" </span>
:key="item.path"
:to="item.path"
class="text-gray-600 dark:text-gray-300 hover:text-emerald-600 dark:hover:text-emerald-400 transition-colors font-medium py-2"
active-class="text-emerald-600 dark:text-emerald-400"
@click="mobileMenuOpen = false"
>
{{ item.label }}
</NuxtLink> </NuxtLink>
<UButton </li>
to="/login" </ul>
variant="outline"
size="sm" <!-- Auth section -->
class="mt-4 w-fit" <div class="mt-12 pt-8 border-t border-ghost-800/50">
@click="mobileMenuOpen = false" <div v-if="isAuthenticated" class="space-y-4">
<NuxtLink
to="/member/dashboard"
class="block text-stone-300 hover:text-stone-100 hover:ethereal-text transition-all duration-300 py-2"
> >
Login <span class="block text-sm text-whisper-400 mb-1">{{
</UButton> memberData?.name || "Member"
}}</span>
Dashboard
</NuxtLink>
<button
@click="logout"
class="text-stone-500 hover:text-stone-300 transition-all duration-300 text-sm"
>
Logout
</button>
</div>
<div v-else class="space-y-4">
<p class="text-stone-400 text-sm mb-4">
Enter your email to receive a login link
</p>
<UForm :state="loginForm" @submit="handleLogin">
<UFormField name="email">
<UInput
v-model="loginForm.email"
type="email"
size="md"
placeholder="your.email@example.com"
class="w-full"
/>
</UFormField>
<UButton
type="submit"
:loading="isLoggingIn"
:disabled="!isLoginFormValid"
size="md"
class="w-full mt-3"
>
Send Magic Link
</UButton>
</UForm>
<div
v-if="loginSuccess"
class="p-3 bg-green-900/20 rounded border border-green-800"
>
<p class="text-green-300 text-sm"> Check your email!</p>
</div>
<div
v-if="loginError"
class="p-3 bg-red-900/20 rounded border border-red-800"
>
<p class="text-red-300 text-sm">{{ loginError }}</p>
</div>
</div> </div>
</div> </div>
</UContainer> </div>
</nav> </nav>
</template> </template>
<script setup> <script setup>
import { ref } from 'vue' import { reactive, ref, computed } from "vue";
const mobileMenuOpen = ref(false) const { isAuthenticated, logout, memberData } = useAuth();
const navigationItems = [ const publicNavigationItems = [
{ label: 'Home', path: '/' }, { label: "Home", path: "/", accent: "entry point" },
{ label: 'About', path: '/about' }, { label: "About", path: "/about", accent: "who we are" },
{ label: 'Events', path: '/events' }, { label: "Events", path: "/events", accent: "gatherings" },
{ label: 'Members', path: '/members' }, { label: "Join", path: "/join", accent: "become one" },
{ label: 'Join', path: '/join' }, { label: "Contact", path: "/contact", accent: "reach out" },
{ label: 'Contact', path: '/contact' }, ];
]
const toggleMobileMenu = () => { const memberNavigationItems = [
mobileMenuOpen.value = !mobileMenuOpen.value { label: "Dashboard", path: "/member/dashboard" },
{ label: "Events", path: "/events" },
{ label: "Members", path: "/members" },
{ label: "Resources", path: "/resources" },
{ label: "Updates", path: "/updates" },
{ label: "Peer Support", path: "/peer-support" },
{ label: "Profile", path: "/member/profile" },
];
const navigationItems = computed(() =>
isAuthenticated.value ? memberNavigationItems : publicNavigationItems,
);
// Login form state
const loginForm = reactive({
email: "",
});
const isLoggingIn = ref(false);
const loginSuccess = ref(false);
const loginError = ref("");
const isLoginFormValid = computed(() => {
return loginForm.email && loginForm.email.includes("@");
});
const handleLogin = async () => {
if (isLoggingIn.value) return;
isLoggingIn.value = true;
loginError.value = "";
loginSuccess.value = false;
try {
const response = await $fetch("/api/auth/login", {
method: "POST",
body: {
email: loginForm.email,
},
});
if (response.success) {
loginSuccess.value = true;
loginError.value = "";
loginForm.email = "";
}
} catch (err) {
console.error("Login error:", err);
if (err.statusCode === 404) {
loginError.value = "No account found";
} else if (err.statusCode === 500) {
loginError.value = "Failed to send email";
} else {
loginError.value = "Something went wrong";
}
} finally {
isLoggingIn.value = false;
}
};
</script>
<style scoped>
@keyframes float {
0%,
100% {
transform: translateY(0) rotate(0deg);
}
50% {
transform: translateY(-5px) rotate(10deg);
}
} }
// Close mobile menu when clicking outside .delay-75 {
const closeMobileMenu = () => { animation-delay: 75ms;
mobileMenuOpen.value = false
} }
</script>
.delay-150 {
animation-delay: 150ms;
}
</style>

View file

@ -0,0 +1,238 @@
<template>
<div class="space-y-2">
<div class="relative">
<input
v-model="naturalInput"
type="text"
:placeholder="placeholder"
:class="[
'w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent',
inputClass,
{
'border-green-300 bg-green-50': isValidParse && naturalInput.trim(),
'border-red-300 bg-red-50': hasError && naturalInput.trim()
}
]"
@input="parseNaturalInput"
@blur="onBlur"
/>
<div v-if="naturalInput.trim()" class="absolute right-3 top-2.5">
<Icon
v-if="isValidParse"
name="heroicons:check-circle"
class="w-5 h-5 text-green-500"
/>
<Icon
v-else-if="hasError"
name="heroicons:exclamation-circle"
class="w-5 h-5 text-red-500"
/>
</div>
</div>
<div v-if="parsedDate && isValidParse" class="text-sm text-green-700 bg-green-50 px-3 py-2 rounded-lg border border-green-200">
<div class="flex items-center gap-2">
<Icon name="heroicons:calendar" class="w-4 h-4" />
<span>{{ formatParsedDate(parsedDate) }}</span>
</div>
</div>
<div v-if="hasError && naturalInput.trim()" class="text-sm text-red-700 bg-red-50 px-3 py-2 rounded-lg border border-red-200">
<div class="flex items-center gap-2">
<Icon name="heroicons:exclamation-triangle" class="w-4 h-4" />
<span>{{ errorMessage }}</span>
</div>
</div>
<!-- Fallback datetime-local input -->
<details class="text-sm">
<summary class="cursor-pointer text-gray-600 hover:text-gray-900">
Use traditional date picker
</summary>
<div class="mt-2">
<input
v-model="datetimeValue"
type="datetime-local"
:class="[
'w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent',
inputClass
]"
@change="onDatetimeChange"
/>
</div>
</details>
</div>
</template>
<script setup>
import * as chrono from 'chrono-node'
const props = defineProps({
modelValue: {
type: String,
default: ''
},
placeholder: {
type: String,
default: 'e.g., "tomorrow at 3pm", "next Friday at 9am", "in 2 hours"'
},
inputClass: {
type: String,
default: ''
},
required: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue'])
const naturalInput = ref('')
const parsedDate = ref(null)
const isValidParse = ref(false)
const hasError = ref(false)
const errorMessage = ref('')
const datetimeValue = ref('')
// Initialize with current value
onMounted(() => {
if (props.modelValue) {
const date = new Date(props.modelValue)
if (!isNaN(date.getTime())) {
parsedDate.value = date
datetimeValue.value = formatForDatetimeLocal(date)
isValidParse.value = true
}
}
})
// Watch for external changes to modelValue
watch(() => props.modelValue, (newValue) => {
if (newValue && newValue !== formatForDatetimeLocal(parsedDate.value)) {
const date = new Date(newValue)
if (!isNaN(date.getTime())) {
parsedDate.value = date
datetimeValue.value = formatForDatetimeLocal(date)
isValidParse.value = true
naturalInput.value = '' // Clear natural input when set externally
}
} else if (!newValue) {
reset()
}
})
const parseNaturalInput = () => {
const input = naturalInput.value.trim()
if (!input) {
reset()
return
}
try {
// Parse with chrono-node
const results = chrono.parse(input)
if (results.length > 0) {
const result = results[0]
const date = result.date()
// Validate the parsed date
if (date && !isNaN(date.getTime())) {
parsedDate.value = date
isValidParse.value = true
hasError.value = false
datetimeValue.value = formatForDatetimeLocal(date)
emit('update:modelValue', formatForDatetimeLocal(date))
} else {
setError('Could not parse this date format')
}
} else {
setError('Could not understand this date format. Try something like "tomorrow at 3pm" or "next Friday"')
}
} catch (error) {
setError('Error parsing date')
}
}
const onBlur = () => {
// If we have a valid parse but the input changed, try to parse again
if (naturalInput.value.trim() && !isValidParse.value) {
parseNaturalInput()
}
}
const onDatetimeChange = () => {
if (datetimeValue.value) {
const date = new Date(datetimeValue.value)
if (!isNaN(date.getTime())) {
parsedDate.value = date
isValidParse.value = true
hasError.value = false
naturalInput.value = '' // Clear natural input when using traditional picker
emit('update:modelValue', datetimeValue.value)
}
} else {
reset()
}
}
const reset = () => {
parsedDate.value = null
isValidParse.value = false
hasError.value = false
errorMessage.value = ''
emit('update:modelValue', '')
}
const setError = (message) => {
isValidParse.value = false
hasError.value = true
errorMessage.value = message
parsedDate.value = null
}
const formatForDatetimeLocal = (date) => {
if (!date) return ''
// Format as YYYY-MM-DDTHH:MM for datetime-local input
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day}T${hours}:${minutes}`
}
const formatParsedDate = (date) => {
if (!date) return ''
const now = new Date()
const isToday = date.toDateString() === now.toDateString()
const tomorrow = new Date(now)
tomorrow.setDate(tomorrow.getDate() + 1)
const isTomorrow = date.toDateString() === tomorrow.toDateString()
const timeStr = date.toLocaleString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
})
if (isToday) {
return `Today at ${timeStr}`
} else if (isTomorrow) {
return `Tomorrow at ${timeStr}`
} else {
return date.toLocaleString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true
})
}
}
</script>

View file

@ -1,65 +1,108 @@
<template> <template>
<header <header
class="py-16 md:py-24" class="relative py-16 md:py-24 bg-cover bg-center"
:class="[ style="background-image: url(&quot;/background-dither.png&quot;)"
backgroundClass,
textColorClass
]"
> >
<UContainer> <div class="absolute inset-0 bg-black/40"></div>
<UContainer class="relative z-10">
<div class="text-center max-w-4xl mx-auto"> <div class="text-center max-w-4xl mx-auto">
<h1 <h1
class="font-bold mb-6 md:mb-8" class="font-bold mb-6 md:mb-8"
:class="[ :class="[titleSizeClass, titleColorClass]"
titleSizeClass,
titleColorClass
]"
> >
{{ title }} {{ title }}
</h1> </h1>
<p <p
v-if="subtitle" v-if="subtitle"
class="text-lg md:text-xl leading-relaxed mb-8" class="text-lg md:text-xl leading-relaxed mb-8"
:class="subtitleColorClass" :class="subtitleColorClass"
> >
{{ subtitle }} {{ subtitle }}
</p> </p>
<!-- Interactive Content Area (for hero sections with carousels, etc.) --> <!-- Interactive Content Area (for hero sections with carousels, etc.) -->
<div v-if="showInteractiveArea" class="bg-white dark:bg-gray-800 rounded-2xl p-6 md:p-8 shadow-xl border border-blue-200 dark:border-blue-800 mb-12"> <div
v-if="showInteractiveArea"
:class="[
'rounded-2xl p-6 md:p-8 mb-12 backdrop-blur-sm',
props.theme === 'ethereal'
? 'bg-ghost-800/60 border border-ghost-700 ethereal-glow halftone-texture'
: 'bg-white dark:bg-gray-800 shadow-xl border border-blue-200 dark:border-blue-800',
]"
>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<button <button
class="p-3 rounded-full bg-blue-500 text-white hover:bg-blue-600 transition-colors" :class="[
'p-3 rounded-full transition-all duration-300',
props.theme === 'ethereal'
? 'bg-whisper-600/80 text-stone-100 hover:bg-whisper-500 ethereal-glow'
: 'bg-blue-500 text-white hover:bg-blue-600',
]"
@click="$emit('prev')" @click="$emit('prev')"
> >
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/> class="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 19l-7-7 7-7"
/>
</svg> </svg>
</button> </button>
<div class="text-center flex-1"> <div class="text-center flex-1">
<slot name="interactive-content"> <slot name="interactive-content">
<p class="text-lg text-gray-600 dark:text-gray-300"> <p
{{ interactiveContent || 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.' }} :class="[
'text-lg',
props.theme === 'ethereal'
? 'text-stone-200'
: 'text-gray-600 dark:text-gray-300',
]"
>
{{
interactiveContent ||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit."
}}
</p> </p>
</slot> </slot>
</div> </div>
<button <button
class="p-3 rounded-full bg-blue-500 text-white hover:bg-blue-600 transition-colors" :class="[
'p-3 rounded-full transition-all duration-300',
props.theme === 'ethereal'
? 'bg-whisper-600/80 text-stone-100 hover:bg-whisper-500 ethereal-glow'
: 'bg-blue-500 text-white hover:bg-blue-600',
]"
@click="$emit('next')" @click="$emit('next')"
> >
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/> class="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg> </svg>
</button> </button>
</div> </div>
</div> </div>
<!-- Call to Action Button --> <!-- Call to Action Button -->
<div v-if="showCta" class="flex justify-center"> <div v-if="showCta" class="flex justify-center">
<UButton <UButton
:to="ctaLink" :to="ctaLink"
:size="ctaSize" :size="ctaSize"
:color="ctaColor" :color="ctaColor"
@ -68,7 +111,7 @@
{{ ctaText }} {{ ctaText }}
</UButton> </UButton>
</div> </div>
<!-- Custom Content Slot --> <!-- Custom Content Slot -->
<div v-if="$slots.default"> <div v-if="$slots.default">
<slot /> <slot />
@ -79,94 +122,93 @@
</template> </template>
<script setup> <script setup>
import { computed } from 'vue' import { computed } from "vue";
const props = defineProps({ const props = defineProps({
title: { title: {
type: String, type: String,
required: true required: true,
}, },
subtitle: { subtitle: {
type: String, type: String,
default: '' default: "",
}, },
theme: { theme: {
type: String, type: String,
default: 'blue', default: "blue",
validator: (value) => ['blue', 'purple', 'emerald', 'gray'].includes(value) validator: (value) =>
["blue", "purple", "emerald", "gray", "ethereal"].includes(value),
}, },
size: { size: {
type: String, type: String,
default: 'large', default: "large",
validator: (value) => ['small', 'medium', 'large', 'hero'].includes(value) validator: (value) => ["small", "medium", "large", "hero"].includes(value),
}, },
showInteractiveArea: { showInteractiveArea: {
type: Boolean, type: Boolean,
default: false default: false,
}, },
interactiveContent: { interactiveContent: {
type: String, type: String,
default: '' default: "",
}, },
showCta: { showCta: {
type: Boolean, type: Boolean,
default: false default: false,
}, },
ctaText: { ctaText: {
type: String, type: String,
default: 'Get Started' default: "Get Started",
}, },
ctaLink: { ctaLink: {
type: String, type: String,
default: '/join' default: "/join",
}, },
ctaSize: { ctaSize: {
type: String, type: String,
default: 'lg' default: "lg",
}, },
ctaColor: { ctaColor: {
type: String, type: String,
default: 'primary' default: "primary",
} },
}) });
defineEmits(['prev', 'next']) defineEmits(["prev", "next"]);
const backgroundClass = computed(() => { const backgroundClass = computed(() => {
const themes = { const themes = {
blue: 'bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-gray-900 dark:to-gray-800', blue: "bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-gray-900 dark:to-gray-800",
purple: 'bg-gradient-to-br from-purple-50 to-violet-100 dark:from-gray-900 dark:to-purple-900/20', purple:
emerald: 'bg-gradient-to-br from-emerald-50 to-teal-100 dark:from-gray-900 dark:to-emerald-900/20', "bg-gradient-to-br from-purple-50 to-violet-100 dark:from-gray-900 dark:to-purple-900/20",
gray: 'bg-gray-50 dark:bg-gray-900' emerald:
} "bg-gradient-to-br from-emerald-50 to-teal-100 dark:from-gray-900 dark:to-emerald-900/20",
return themes[props.theme] || themes.blue gray: "bg-gray-50 dark:bg-gray-900",
}) ethereal:
"bg-gradient-to-br from-ghost-900 via-ghost-800 to-whisper-900 halftone-texture",
};
return themes[props.theme] || themes.blue;
});
const titleSizeClass = computed(() => { const titleSizeClass = computed(() => {
const sizes = { const sizes = {
small: 'text-2xl md:text-3xl', small: "text-2xl md:text-3xl",
medium: 'text-3xl md:text-4xl', medium: "text-3xl md:text-4xl",
large: 'text-4xl md:text-5xl', large: "text-4xl md:text-5xl",
hero: 'text-5xl md:text-6xl' hero: "text-5xl md:text-6xl",
} };
return sizes[props.size] || sizes.large return sizes[props.size] || sizes.large;
}) });
const titleColorClass = computed(() => { const titleColorClass = computed(() => {
const themes = { return "text-white";
blue: 'text-blue-600 dark:text-blue-400', });
purple: 'text-purple-600 dark:text-purple-400',
emerald: 'text-emerald-600 dark:text-emerald-400',
gray: 'text-gray-900 dark:text-white'
}
return themes[props.theme] || themes.blue
})
const subtitleColorClass = computed(() => { const subtitleColorClass = computed(() => {
return 'text-gray-600 dark:text-gray-300' return "text-white";
}) });
const textColorClass = computed(() => { const textColorClass = computed(() => {
return 'text-gray-900 dark:text-white' return "text-white";
}) });
</script> </script>

View file

@ -0,0 +1,47 @@
<template>
<div class="flex items-center gap-2 text-sm">
<span class="text-gray-600 dark:text-gray-400">{{ label }}:</span>
<UButtonGroup size="xs">
<UButton
:variant="modelValue === 'public' ? 'solid' : 'ghost'"
:color="modelValue === 'public' ? 'blue' : 'gray'"
@click="updateValue('public')"
>
Public
</UButton>
<UButton
:variant="modelValue === 'members' ? 'solid' : 'ghost'"
:color="modelValue === 'members' ? 'blue' : 'gray'"
@click="updateValue('members')"
>
Members
</UButton>
<UButton
:variant="modelValue === 'private' ? 'solid' : 'ghost'"
:color="modelValue === 'private' ? 'blue' : 'gray'"
@click="updateValue('private')"
>
Private
</UButton>
</UButtonGroup>
</div>
</template>
<script setup>
const props = defineProps({
modelValue: {
type: String,
default: 'members'
},
label: {
type: String,
default: 'Privacy'
}
})
const emit = defineEmits(['update:modelValue'])
const updateValue = (value) => {
emit('update:modelValue', value)
}
</script>

View file

@ -0,0 +1,189 @@
<template>
<UCard variant="outline" class="update-card">
<div class="flex gap-4">
<!-- Avatar -->
<div class="flex-shrink-0">
<img
v-if="update.author?.avatar"
:src="`/ghosties/Ghost-${capitalize(update.author.avatar)}.png`"
:alt="update.author.name"
class="w-12 h-12 rounded-full"
@error="handleImageError"
/>
<div
v-else
class="w-12 h-12 rounded-full bg-stone-700 flex items-center justify-center text-stone-300 font-bold"
>
{{ update.author?.name?.charAt(0)?.toUpperCase() || "?" }}
</div>
</div>
<!-- Content -->
<div class="flex-1 min-w-0">
<!-- Header -->
<div class="flex items-start justify-between gap-4 mb-2">
<div>
<h3 class="font-semibold text-stone-100">
<NuxtLink
v-if="update.author?._id"
:to="`/updates/user/${update.author._id}`"
class="hover:text-stone-300 transition-colors"
>
{{ update.author.name }}
</NuxtLink>
<span v-else>Unknown Member</span>
</h3>
<div class="flex items-center gap-2 text-sm text-stone-400">
<time :datetime="update.createdAt">
{{ formatDate(update.createdAt) }}
</time>
<span v-if="isEdited" class="text-stone-500">(edited)</span>
<span
v-if="update.privacy === 'private'"
class="px-2 py-0.5 bg-stone-700 text-stone-300 rounded text-xs"
>
Private
</span>
<span
v-if="update.privacy === 'public'"
class="px-2 py-0.5 bg-stone-700 text-stone-300 rounded text-xs"
>
Public
</span>
</div>
</div>
<!-- Actions (for author only) -->
<div v-if="isAuthor" class="flex gap-2">
<UButton
variant="ghost"
color="neutral"
size="xs"
icon="i-lucide-edit"
@click="$emit('edit', update)"
/>
<UButton
variant="ghost"
color="neutral"
size="xs"
icon="i-lucide-trash-2"
@click="$emit('delete', update)"
/>
</div>
</div>
<!-- Content -->
<div class="text-stone-200 whitespace-pre-wrap break-words mb-3">
<template v-if="showPreview && update.content.length > 300">
{{ update.content.substring(0, 300) }}...
<NuxtLink
:to="`/updates/${update._id}`"
class="text-stone-400 hover:text-stone-300 ml-1"
>
Read more
</NuxtLink>
</template>
<template v-else>
{{ update.content }}
</template>
</div>
<!-- Images (if any) -->
<div v-if="update.images?.length" class="mb-3 space-y-2">
<img
v-for="(image, index) in update.images"
:key="index"
:src="image.url"
:alt="image.alt || 'Update image'"
class="rounded-lg max-w-full h-auto"
/>
</div>
<!-- Footer actions -->
<div class="flex items-center gap-4 text-sm text-stone-400">
<NuxtLink
:to="`/updates/${update._id}`"
class="hover:text-stone-300 transition-colors"
>
View full update
</NuxtLink>
<span v-if="update.commentsEnabled" class="text-stone-500">
Comments (coming soon)
</span>
</div>
</div>
</div>
</UCard>
</template>
<script setup>
const props = defineProps({
update: {
type: Object,
required: true,
},
showPreview: {
type: Boolean,
default: true,
},
});
defineEmits(["edit", "delete"]);
const { memberData } = useAuth();
const isAuthor = computed(() => {
return memberData.value && props.update.author?._id === memberData.value.id;
});
const isEdited = computed(() => {
const created = new Date(props.update.createdAt).getTime();
const updated = new Date(props.update.updatedAt).getTime();
return updated - created > 1000; // More than 1 second difference
});
const capitalize = (str) => {
if (!str) return "";
return str.charAt(0).toUpperCase() + str.slice(1);
};
const handleImageError = (e) => {
e.target.src = "/ghosties/Ghost-Mild.png"; // Fallback ghost
};
const formatDate = (date) => {
const now = new Date();
const updateDate = new Date(date);
const diffInSeconds = Math.floor((now - updateDate) / 1000);
if (diffInSeconds < 60) return "just now";
if (diffInSeconds < 3600)
return `${Math.floor(diffInSeconds / 60)} minutes ago`;
if (diffInSeconds < 86400)
return `${Math.floor(diffInSeconds / 3600)} hours ago`;
if (diffInSeconds < 604800)
return `${Math.floor(diffInSeconds / 86400)} days ago`;
return updateDate.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year:
updateDate.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
});
};
</script>
<style scoped>
.update-card {
background-color: rgb(41 37 36);
border-color: rgb(87 83 78);
}
.update-card:hover {
border-color: rgb(120 113 108);
}
:deep(.card) {
background-color: rgb(41 37 36);
}
</style>

View file

@ -0,0 +1,182 @@
<template>
<div class="space-y-6">
<UFormField label="What's on your mind?" name="content" required>
<UTextarea
v-model="formData.content"
placeholder="Share your thoughts, updates, questions, or learnings with the community..."
:rows="8"
autoresize
:maxrows="20"
/>
</UFormField>
<!-- Privacy Settings -->
<div class="border border-stone-700 rounded-lg p-4 bg-stone-800/30">
<h3 class="text-sm font-medium text-stone-200 mb-4">Privacy Settings</h3>
<div class="space-y-3">
<label class="flex items-center gap-3 cursor-pointer">
<input
v-model="formData.privacy"
type="radio"
value="public"
class="w-4 h-4 text-stone-400"
/>
<div>
<div class="text-stone-200 font-medium">Public</div>
<div class="text-sm text-stone-400">
Visible to everyone, including non-members
</div>
</div>
</label>
<label class="flex items-center gap-3 cursor-pointer">
<input
v-model="formData.privacy"
type="radio"
value="members"
class="w-4 h-4 text-stone-400"
/>
<div>
<div class="text-stone-200 font-medium">Members Only</div>
<div class="text-sm text-stone-400">
Only visible to Ghost Guild members
</div>
</div>
</label>
<label class="flex items-center gap-3 cursor-pointer">
<input
v-model="formData.privacy"
type="radio"
value="private"
class="w-4 h-4 text-stone-400"
/>
<div>
<div class="text-stone-200 font-medium">Private</div>
<div class="text-sm text-stone-400">Only visible to you</div>
</div>
</label>
</div>
</div>
<!-- Image Upload (Future) -->
<!-- TODO: Add image upload integration with Cloudinary -->
<!-- Comments Toggle -->
<div class="flex items-center gap-3">
<USwitch v-model="formData.commentsEnabled" />
<div>
<div class="text-stone-200 font-medium">Enable Comments</div>
<div class="text-sm text-stone-400">
Allow members to comment on this update
</div>
</div>
</div>
<!-- Actions -->
<div class="flex justify-between items-center pt-4 border-t border-stone-700">
<UButton variant="ghost" color="neutral" @click="$emit('cancel')">
Cancel
</UButton>
<UButton
:loading="submitting"
:disabled="!formData.content.trim()"
@click="handleSubmit"
>
{{ submitLabel }}
</UButton>
</div>
<!-- Error Message -->
<div
v-if="error"
class="bg-red-500/10 border border-red-500/30 rounded-lg p-4"
>
<p class="text-red-300">{{ error }}</p>
</div>
</div>
</template>
<script setup>
const props = defineProps({
initialData: {
type: Object,
default: () => ({
content: "",
privacy: "members",
commentsEnabled: true,
images: [],
}),
},
submitLabel: {
type: String,
default: "Post Update",
},
submitting: {
type: Boolean,
default: false,
},
error: {
type: String,
default: null,
},
});
const emit = defineEmits(["submit", "cancel"]);
const formData = reactive({
content: props.initialData.content || "",
privacy: props.initialData.privacy || "members",
commentsEnabled: props.initialData.commentsEnabled ?? true,
images: props.initialData.images || [],
});
const handleSubmit = () => {
if (!formData.content.trim()) return;
emit("submit", { ...formData });
};
// Watch for initialData changes (for edit mode)
watch(
() => props.initialData,
(newData) => {
if (newData) {
formData.content = newData.content || "";
formData.privacy = newData.privacy || "members";
formData.commentsEnabled = newData.commentsEnabled ?? true;
formData.images = newData.images || [];
}
},
{ immediate: true },
);
</script>
<style scoped>
/* Field labels */
:deep(label) {
color: rgb(231 229 228) !important;
font-weight: 500;
}
/* Textarea styling */
:deep(textarea) {
background-color: rgb(41 37 36) !important;
color: rgb(231 229 228) !important;
border-color: rgb(87 83 78) !important;
}
:deep(textarea::placeholder) {
color: rgb(120 113 108) !important;
}
:deep(textarea:focus) {
border-color: rgb(168 162 158) !important;
background-color: rgb(44 40 39) !important;
}
/* Radio buttons */
input[type="radio"] {
accent-color: rgb(168 162 158);
}
</style>

View file

@ -0,0 +1,46 @@
export const useAuth = () => {
const memberData = useState('auth.member', () => null)
const isAuthenticated = computed(() => !!memberData.value)
const isMember = computed(() => !!memberData.value)
const checkMemberStatus = async () => {
console.log('🔍 checkMemberStatus called')
console.log(' - Current memberData:', !!memberData.value)
try {
console.log(' - Making API call to /api/auth/member...')
const response = await $fetch('/api/auth/member')
console.log(' - API response received:', { email: response.email, id: response.id })
memberData.value = response
console.log(' - ✅ Member authenticated successfully')
return true
} catch (error) {
console.error(' - ❌ Failed to fetch member status:', error.statusCode, error.statusMessage)
memberData.value = null
console.log(' - Cleared memberData')
return false
}
}
const logout = async () => {
try {
await $fetch('/api/auth/logout', {
method: 'POST'
})
memberData.value = null
await navigateTo('/')
} catch (error) {
console.error('Logout failed:', error)
}
}
return {
isAuthenticated: readonly(isAuthenticated),
isMember: readonly(isMember),
memberData: readonly(memberData),
checkMemberStatus,
logout
}
}

View file

@ -0,0 +1,90 @@
// Helcim API integration composable
export const useHelcim = () => {
const config = useRuntimeConfig()
const helcimToken = config.public.helcimToken
// Base URL for Helcim API
const HELCIM_API_BASE = 'https://api.helcim.com/v2'
// Helper function to make API requests
const makeHelcimRequest = async (endpoint, method = 'GET', body = null) => {
try {
const response = await $fetch(`${HELCIM_API_BASE}${endpoint}`, {
method,
headers: {
'accept': 'application/json',
'content-type': 'application/json',
'api-token': helcimToken
},
body: body ? JSON.stringify(body) : undefined
})
return response
} catch (error) {
console.error('Helcim API error:', error)
throw error
}
}
// Create a customer
const createCustomer = async (customerData) => {
return await makeHelcimRequest('/customers', 'POST', {
customerType: 'PERSON',
contactName: customerData.name,
email: customerData.email,
billingAddress: customerData.billingAddress || {}
})
}
// Create a subscription
const createSubscription = async (customerId, planId, cardToken) => {
return await makeHelcimRequest('/recurring/subscriptions', 'POST', {
customerId,
planId,
cardToken,
startDate: new Date().toISOString().split('T')[0] // Today's date
})
}
// Get customer details
const getCustomer = async (customerId) => {
return await makeHelcimRequest(`/customers/${customerId}`)
}
// Get subscription details
const getSubscription = async (subscriptionId) => {
return await makeHelcimRequest(`/recurring/subscriptions/${subscriptionId}`)
}
// Update subscription
const updateSubscription = async (subscriptionId, updates) => {
return await makeHelcimRequest(`/recurring/subscriptions/${subscriptionId}`, 'PATCH', updates)
}
// Cancel subscription
const cancelSubscription = async (subscriptionId) => {
return await makeHelcimRequest(`/recurring/subscriptions/${subscriptionId}`, 'DELETE')
}
// Get payment plans
const getPaymentPlans = async () => {
return await makeHelcimRequest('/recurring/plans')
}
// Verify card token (for testing)
const verifyCardToken = async (cardToken) => {
return await makeHelcimRequest('/cards/verify', 'POST', {
cardToken
})
}
return {
createCustomer,
createSubscription,
getCustomer,
getSubscription,
updateSubscription,
cancelSubscription,
getPaymentPlans,
verifyCardToken
}
}

View file

@ -0,0 +1,158 @@
// HelcimPay.js integration composable
export const useHelcimPay = () => {
let checkoutToken = null
let secretToken = null
// Initialize HelcimPay.js session
const initializeHelcimPay = async (customerId, customerCode, amount = 0) => {
try {
const response = await $fetch('/api/helcim/initialize-payment', {
method: 'POST',
body: {
customerId,
customerCode,
amount
}
})
if (response.success) {
checkoutToken = response.checkoutToken
secretToken = response.secretToken
return true
}
throw new Error('Failed to initialize payment session')
} catch (error) {
console.error('Payment initialization error:', error)
throw error
}
}
// Show payment modal
const showPaymentModal = () => {
return new Promise((resolve, reject) => {
if (!checkoutToken) {
reject(new Error('Payment not initialized. Call initializeHelcimPay first.'))
return
}
// Load HelcimPay.js modal script
if (!window.appendHelcimPayIframe) {
console.log('HelcimPay script not loaded, loading now...')
const script = document.createElement('script')
script.src = 'https://secure.helcim.app/helcim-pay/services/start.js'
script.async = true
script.onload = () => {
console.log('HelcimPay script loaded successfully!')
console.log('Available functions:', Object.keys(window).filter(key => key.includes('Helcim') || key.includes('helcim')))
console.log('appendHelcimPayIframe available:', typeof window.appendHelcimPayIframe)
openModal(resolve, reject)
}
script.onerror = () => {
reject(new Error('Failed to load HelcimPay.js'))
}
document.head.appendChild(script)
} else {
console.log('HelcimPay script already loaded, calling openModal')
openModal(resolve, reject)
}
})
}
// Open the payment modal
const openModal = (resolve, reject) => {
try {
console.log('Trying to open modal with checkoutToken:', checkoutToken)
if (typeof window.appendHelcimPayIframe === 'function') {
// Set up event listener for HelcimPay.js responses
const helcimPayJsIdentifierKey = 'helcim-pay-js-' + checkoutToken
const handleHelcimPayEvent = (event) => {
console.log('Received window message:', event.data)
if (event.data.eventName === helcimPayJsIdentifierKey) {
console.log('HelcimPay event received:', event.data)
// Remove event listener to prevent multiple responses
window.removeEventListener('message', handleHelcimPayEvent)
if (event.data.eventStatus === 'SUCCESS') {
console.log('Payment success:', event.data.eventMessage)
// Parse the JSON string eventMessage
let paymentData
try {
paymentData = JSON.parse(event.data.eventMessage)
console.log('Parsed payment data:', paymentData)
} catch (parseError) {
console.error('Failed to parse eventMessage:', parseError)
reject(new Error('Invalid payment response format'))
return
}
// Extract transaction details from nested data structure
const transactionData = paymentData.data?.data || {}
console.log('Transaction data:', transactionData)
resolve({
success: true,
transactionId: transactionData.transactionId,
cardToken: transactionData.cardToken,
cardLast4: transactionData.cardNumber ? transactionData.cardNumber.slice(-4) : undefined,
cardType: transactionData.cardType || 'unknown'
})
} else if (event.data.eventStatus === 'ABORTED') {
console.log('Payment aborted:', event.data.eventMessage)
reject(new Error(event.data.eventMessage || 'Payment failed'))
} else if (event.data.eventStatus === 'HIDE') {
console.log('Modal closed without completion')
reject(new Error('Payment cancelled by user'))
}
}
}
// Add event listener
window.addEventListener('message', handleHelcimPayEvent)
// Open the HelcimPay iframe modal
console.log('Calling appendHelcimPayIframe with token:', checkoutToken)
window.appendHelcimPayIframe(checkoutToken, true)
console.log('appendHelcimPayIframe called, waiting for window messages...')
// Add timeout to clean up if no response
setTimeout(() => {
console.log('60 seconds passed, cleaning up event listener...')
window.removeEventListener('message', handleHelcimPayEvent)
reject(new Error('Payment timeout - no response received'))
}, 60000)
} else {
reject(new Error('appendHelcimPayIframe function not available'))
}
} catch (error) {
console.error('Error opening modal:', error)
reject(error)
}
}
// Process payment verification
const verifyPayment = async () => {
try {
return await showPaymentModal()
} catch (error) {
throw error
}
}
// Cleanup tokens
const cleanup = () => {
checkoutToken = null
secretToken = null
}
return {
initializeHelcimPay,
verifyPayment,
cleanup
}
}

View file

@ -57,18 +57,18 @@
</NuxtLink> </NuxtLink>
<NuxtLink <NuxtLink
to="/admin/analytics" to="/admin/series-management"
:class="[ :class="[
'px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200', 'px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200',
$route.path.includes('/admin/analytics') $route.path.includes('/admin/series')
? 'bg-blue-100 text-blue-700 shadow-sm' ? 'bg-blue-100 text-blue-700 shadow-sm'
: 'text-gray-600 hover:text-blue-600 hover:bg-blue-50' : 'text-gray-600 hover:text-blue-600 hover:bg-blue-50'
]" ]"
> >
<svg class="w-4 h-4 inline-block mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 inline-block mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/>
</svg> </svg>
Analytics Series
</NuxtLink> </NuxtLink>
</div> </div>
</div> </div>
@ -159,15 +159,15 @@
</NuxtLink> </NuxtLink>
<NuxtLink <NuxtLink
to="/admin/analytics" to="/admin/series-management"
:class="[ :class="[
'px-3 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-colors', 'px-3 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-colors',
$route.path.includes('/admin/analytics') $route.path.includes('/admin/series')
? 'bg-blue-100 text-blue-700' ? 'bg-blue-100 text-blue-700'
: 'text-gray-600 hover:text-blue-600 hover:bg-blue-50' : 'text-gray-600 hover:text-blue-600 hover:bg-blue-50'
]" ]"
> >
Analytics Series
</NuxtLink> </NuxtLink>
</div> </div>
</div> </div>

View file

@ -1,7 +1,35 @@
<template> <template>
<div> <div class="min-h-screen bg-stone-800 flex relative">
<AppNavigation /> <!-- Background image at top - full page width -->
<slot /> <div
<AppFooter /> class="absolute inset-x-0 pointer-events-none z-0"
style="
background-image: url(&quot;/background-dither.png&quot;);
background-size: 100% auto;
background-position: top center;
background-repeat: no-repeat;
mask-image: linear-gradient(
to bottom,
rgba(0, 0, 0, 1) 0%,
rgba(0, 0, 0, 0) 100%
);
-webkit-mask-image: linear-gradient(
to bottom,
rgba(0, 0, 0, 1) 0%,
rgba(0, 0, 0, 0) 100%
);
"
/>
<!-- Main Content Column - Left -->
<div class="flex-1 overflow-y-auto relative z-[5]">
<div class="p-8 md:p-12 lg:p-16 max-w-4xl relative">
<slot />
</div>
<AppFooter />
</div>
<!-- Navigation Column - Right -->
<AppNavigation class="relative z-20" />
</div> </div>
</template> </template>

27
app/middleware/auth.js Normal file
View file

@ -0,0 +1,27 @@
export default defineNuxtRouteMiddleware(async (to, from) => {
// Skip on server-side rendering
if (process.server) {
console.log('🛡️ Auth middleware - skipping on server')
return
}
const { memberData, checkMemberStatus } = useAuth()
console.log('🛡️ Auth middleware (CLIENT) - route:', to.path)
console.log(' - memberData exists:', !!memberData.value)
console.log(' - Running on:', process.server ? 'SERVER' : 'CLIENT')
// If no member data, try to check authentication
if (!memberData.value) {
console.log(' - No member data, checking authentication...')
const isAuthenticated = await checkMemberStatus()
console.log(' - Authentication result:', isAuthenticated)
if (!isAuthenticated) {
console.log(' - ❌ Authentication failed, redirecting to login')
return navigateTo('/login')
}
}
console.log(' - ✅ Authentication successful for:', memberData.value?.email)
})

View file

@ -1,306 +1,278 @@
<template> <template>
<div> <div>
<!-- Page Header --> <!-- Page Header -->
<PageHeader <PageHeader
title="About" title="About Our Membership Circles"
subtitle="Learn about Ghost Guild, our mission to support cooperative game development, and the community we're building together." subtitle="All members of Ghost Guild share the Baby Ghosts mission: Advancing cooperative and worker-centric labour models in the Canadian interactive digital arts sector."
theme="blue" theme="blue"
size="large" size="large"
/> />
<!-- About Ghost Guild --> <!-- How Ghost Guild Works -->
<section class="py-20 bg-white dark:bg-gray-900"> <section class="py-20 bg-white dark:bg-gray-900">
<UContainer> <UContainer>
<div class="max-w-4xl mx-auto"> <div class="max-w-4xl mx-auto">
<div class="text-center mb-12"> <h2 class="text-3xl font-bold text-gray-900 dark:text-white mb-6">
<h2 class="text-3xl font-bold text-blue-600 dark:text-blue-400 mb-8"> How Ghost Guild Works
About Ghost Guild </h2>
</h2>
</div>
<div class="space-y-8"> <div class="prose prose-lg dark:prose-invert max-w-none">
<!-- Main Description with Progress Bars --> <p class="text-xl font-semibold text-gray-900 dark:text-white mb-6">
<div class="bg-blue-50 dark:bg-blue-900/20 rounded-2xl p-8 border border-blue-200 dark:border-blue-800"> Everyone gets everything. Your circle reflects where you are in
<div class="space-y-6 mb-8"> your cooperative journey. Your financial contribution reflects
<div class="h-2 bg-blue-500 rounded-full" /> what you can afford. These are completely separate choices.
<div class="h-2 bg-blue-400 rounded-full w-5/6" /> </p>
<div class="h-2 bg-blue-300 rounded-full w-2/3" />
</div>
<div class="prose prose-lg dark:prose-invert max-w-none">
<p class="text-lg leading-relaxed text-gray-700 dark:text-gray-300 mb-6">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ghost Guild is a cooperative community dedicated to supporting game developers who want to build sustainable, worker-owned studios. We believe in the power of collaboration and shared ownership to create better working conditions and more innovative games.
</p>
<p class="text-lg leading-relaxed text-gray-700 dark:text-gray-300 mb-6">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Our community provides resources, mentorship, and financial support to help developers transition from traditional employment to cooperative ownership models. We're building a network of studios that prioritize worker wellbeing and creative freedom.
</p>
<p class="text-lg leading-relaxed text-gray-700 dark:text-gray-300">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Through our various circles and contribution-based membership model, we create an inclusive space where developers at all stages of their cooperative journey can find support and guidance.
</p>
</div>
</div>
<!-- Mission Statement --> <ul
<div class="grid grid-cols-1 md:grid-cols-2 gap-8"> class="text-lg leading-relaxed text-gray-700 dark:text-gray-300 space-y-3 mb-12"
<div class="bg-white dark:bg-gray-800 rounded-xl p-8 shadow-lg border border-gray-200 dark:border-gray-700"> >
<h3 class="text-xl font-semibold mb-4 text-blue-600 dark:text-blue-400">Our Mission</h3> <li>
<p class="text-gray-600 dark:text-gray-300 leading-relaxed"> <strong>Equal access:</strong> The entire knowledge commons, all
Lorem ipsum dolor sit amet, consectetur adipiscing elit. To democratize game development by empowering developers to create worker-owned studios that prioritize sustainability, creativity, and fair compensation. events, and full community participation
</p> </li>
</div> <li>
<strong>Equal voice:</strong> One member, one vote in all
<div class="bg-white dark:bg-gray-800 rounded-xl p-8 shadow-lg border border-gray-200 dark:border-gray-700"> decisions
<h3 class="text-xl font-semibold mb-4 text-blue-600 dark:text-blue-400">Our Vision</h3> </li>
<p class="text-gray-600 dark:text-gray-300 leading-relaxed"> <li>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. A thriving ecosystem of cooperative game studios that create innovative experiences while providing their worker-owners with meaningful, sustainable careers. <strong>Solidarity economics:</strong> Pay what you can
</p> ($0-50+/month), take what you need
</div> </li>
</div> <li>
<strong>Value Flow integration:</strong> Contribute your skills,
time, and knowledge - not just money
</li>
</ul>
</div> </div>
</div> </div>
</UContainer> </UContainer>
</section> </section>
<!-- Who It's For --> <!-- Find Your Circle -->
<section class="py-20 bg-gray-50 dark:bg-gray-800"> <section class="py-20 bg-gray-50 dark:bg-gray-800">
<UContainer> <UContainer>
<div class="text-center mb-16"> <div class="max-w-4xl mx-auto">
<h2 class="text-3xl font-bold text-blue-600 dark:text-blue-400 mb-8"> <h2 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">
Who It's For Find Your Circle
</h2> </h2>
<p class="text-gray-600 dark:text-gray-300 max-w-2xl mx-auto"> <p class="text-lg text-gray-700 dark:text-gray-300 mb-12">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ghost Guild welcomes developers from all backgrounds and experience levels. Circles help us provide relevant guidance and connect you with
others at similar stages. Choose based on where you are, not what
you want to access.
</p> </p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-12 max-w-5xl mx-auto"> <div class="space-y-12">
<!-- Game Developers --> <!-- Community Circle -->
<div class="flex items-start gap-6"> <div class="bg-white dark:bg-gray-900 rounded-xl p-8">
<div class="flex-shrink-0"> <h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">
<div class="w-16 h-16 bg-blue-100 dark:bg-blue-900/30 rounded-2xl flex items-center justify-center"> Community Circle
<div class="w-8 h-8 bg-blue-500" style="clip-path: polygon(50% 0%, 0% 100%, 100% 100%)" />
</div>
</div>
<div class="flex-1">
<h3 class="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
Game Developers
</h3> </h3>
<div class="space-y-2 mb-4"> <p class="text-lg text-gray-600 dark:text-gray-400 mb-6">
<div class="h-1 bg-blue-500 rounded-full w-full" /> You're exploring what cooperatives could mean for your work
<div class="h-1 bg-blue-300 rounded-full w-3/4" />
<div class="h-1 bg-blue-200 rounded-full w-1/2" />
</div>
<p class="text-gray-600 dark:text-gray-400 leading-relaxed">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Whether you're a solo indie developer, part of a small team, or working at a larger studio, our community provides resources for exploring cooperative models and building sustainable careers in game development.
</p> </p>
</div>
</div>
<!-- Studio Founders --> <div class="mb-6">
<div class="flex items-start gap-6"> <h4
<div class="flex-shrink-0"> class="text-lg font-semibold text-gray-900 dark:text-white mb-3"
<div class="w-16 h-16 bg-emerald-100 dark:bg-emerald-900/30 rounded-2xl flex items-center justify-center"> >
<div class="w-8 h-8 bg-emerald-500 rounded" /> Where you might be:
</h4>
<ul class="text-gray-700 dark:text-gray-300 space-y-2">
<li>
Curious about alternatives to traditional studio structures
</li>
<li>Researching cooperative principles</li>
<li>Considering if a co-op is right for you</li>
<li>Supporting the movement as an ally</li>
</ul>
</div> </div>
</div>
<div class="flex-1">
<h3 class="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
Studio Founders
</h3>
<div class="space-y-2 mb-4">
<div class="h-1 bg-emerald-500 rounded-full w-full" />
<div class="h-1 bg-emerald-300 rounded-full w-5/6" />
<div class="h-1 bg-emerald-200 rounded-full w-2/3" />
</div>
<p class="text-gray-600 dark:text-gray-400 leading-relaxed">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Entrepreneurs and leaders who want to build studios that prioritize worker ownership, democratic decision-making, and sustainable business practices will find mentorship and practical guidance in our community.
</p>
</div>
</div>
<!-- Industry Allies --> <div class="mb-6">
<div class="flex items-start gap-6"> <h4
<div class="flex-shrink-0"> class="text-lg font-semibold text-gray-900 dark:text-white mb-3"
<div class="w-16 h-16 bg-purple-100 dark:bg-purple-900/30 rounded-2xl flex items-center justify-center"> >
<div class="w-8 h-8 bg-purple-500 rounded-full" /> We'll help you navigate:
</h4>
<ul class="text-gray-700 dark:text-gray-300 space-y-2">
<li>Understanding cooperative basics</li>
<li>Connecting with others asking similar questions</li>
<li>Exploring real examples from game studios</li>
<li>Deciding your next steps</li>
</ul>
</div> </div>
</div>
<div class="flex-1">
<h3 class="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
Industry Allies
</h3>
<div class="space-y-2 mb-4">
<div class="h-1 bg-purple-500 rounded-full w-4/5" />
<div class="h-1 bg-purple-300 rounded-full w-full" />
<div class="h-1 bg-purple-200 rounded-full w-3/5" />
</div>
<p class="text-gray-600 dark:text-gray-400 leading-relaxed">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Investors, publishers, service providers, and other industry professionals who want to support cooperative game development and learn about alternative business models.
</p>
</div>
</div>
<!-- Researchers & Advocates --> <div>
<div class="flex items-start gap-6"> <h4
<div class="flex-shrink-0"> class="text-lg font-semibold text-gray-900 dark:text-white mb-3"
<div class="w-16 h-16 bg-yellow-100 dark:bg-yellow-900/30 rounded-2xl flex items-center justify-center"> >
<div class="w-8 h-8 bg-yellow-500 rounded-sm" /> You might be:
</h4>
<ul class="text-gray-700 dark:text-gray-300 space-y-2">
<li>Individual game workers</li>
<li>Researchers and students</li>
<li>Industry allies and supporters</li>
<li>Anyone co-op-curious</li>
</ul>
</div> </div>
</div> </div>
<div class="flex-1">
<h3 class="text-xl font-semibold mb-4 text-gray-900 dark:text-white"> <!-- Founder Circle -->
Researchers & Advocates <div class="bg-white dark:bg-gray-900 rounded-xl p-8">
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">
Founder Circle
</h3> </h3>
<div class="space-y-2 mb-4"> <p class="text-lg text-gray-600 dark:text-gray-400 mb-6">
<div class="h-1 bg-yellow-500 rounded-full w-3/4" /> You're actively building or transitioning to a cooperative model
<div class="h-1 bg-yellow-300 rounded-full w-full" />
<div class="h-1 bg-yellow-200 rounded-full w-1/2" />
</div>
<p class="text-gray-600 dark:text-gray-400 leading-relaxed">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Academics, journalists, and advocates studying cooperative economics, worker ownership, and alternative organizational structures in the creative industries.
</p> </p>
<div class="mb-6">
<h4
class="text-lg font-semibold text-gray-900 dark:text-white mb-3"
>
Where you might be:
</h4>
<ul class="text-gray-700 dark:text-gray-300 space-y-2">
<li>Forming a new cooperative studio</li>
<li>Converting an existing studio to a co-op</li>
<li>Preparing to apply for the Peer Accelerator</li>
<li>Working through governance and structure decisions</li>
</ul>
</div>
<div class="mb-6">
<h4
class="text-lg font-semibold text-gray-900 dark:text-white mb-3"
>
We'll help you navigate:
</h4>
<ul class="text-gray-700 dark:text-gray-300 space-y-2">
<li>Practical implementation challenges</li>
<li>Governance document creation</li>
<li>Financial planning for co-ops</li>
<li>Peer connections with other founders</li>
<li>Balancing ideals with sustainability</li>
</ul>
</div>
<div class="mb-6">
<h4
class="text-lg font-semibold text-gray-900 dark:text-white mb-3"
>
Two paths available:
</h4>
<ul class="text-gray-700 dark:text-gray-300 space-y-2">
<li>
<strong>Peer Accelerator Prep Track:</strong> Structured
preparation for the PA program
</li>
<li>
<strong>Indie Track:</strong> Self-paced development for
alternative pathways
</li>
</ul>
</div>
<div>
<h4
class="text-lg font-semibold text-gray-900 dark:text-white mb-3"
>
You might be:
</h4>
<ul class="text-gray-700 dark:text-gray-300 space-y-2">
<li>Founding teams</li>
<li>Studios in transition</li>
<li>PA program applicants</li>
<li>Solo founders exploring structures</li>
</ul>
</div>
</div>
<!-- Practitioner Circle -->
<div class="bg-white dark:bg-gray-900 rounded-xl p-8">
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">
Practitioner Circle
</h3>
<p class="text-lg text-gray-600 dark:text-gray-400 mb-6">
You're operating a cooperative and contributing to the field
</p>
<div class="mb-6">
<h4
class="text-lg font-semibold text-gray-900 dark:text-white mb-3"
>
Where you might be:
</h4>
<ul class="text-gray-700 dark:text-gray-300 space-y-2">
<li>Running an established cooperative studio</li>
<li>Graduated from the Peer Accelerator</li>
<li>Mentoring other cooperatives</li>
<li>Advancing cooperative practices in games</li>
</ul>
</div>
<div class="mb-6">
<h4
class="text-lg font-semibold text-gray-900 dark:text-white mb-3"
>
We'll help you navigate:
</h4>
<ul class="text-gray-700 dark:text-gray-300 space-y-2">
<li>Advanced operational challenges</li>
<li>Opportunities to mentor and teach</li>
<li>Contributing to best practices</li>
<li>Cross-pollination with other co-ops</li>
<li>Research and publication opportunities</li>
<li>Co-op to co-op collaboration</li>
</ul>
</div>
<div>
<h4
class="text-lg font-semibold text-gray-900 dark:text-white mb-3"
>
You might be:
</h4>
<ul class="text-gray-700 dark:text-gray-300 space-y-2">
<li>Peer Accelerator alumni</li>
<li>Established co-op members</li>
<li>Industry mentors</li>
<li>Cooperative researchers</li>
</ul>
</div>
</div> </div>
</div> </div>
</div> </div>
</UContainer> </UContainer>
</section> </section>
<!-- Our Values --> <!-- Important Notes -->
<section class="py-20 bg-white dark:bg-gray-900"> <section class="py-20 bg-white dark:bg-gray-900">
<UContainer> <UContainer>
<div class="text-center mb-16"> <div class="max-w-4xl mx-auto">
<h2 class="text-3xl font-bold text-blue-600 dark:text-blue-400 mb-8"> <h2 class="text-3xl font-bold text-gray-900 dark:text-white mb-8">
Our Values Important Notes
</h2> </h2>
<p class="text-gray-600 dark:text-gray-300 max-w-2xl mx-auto">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. These core principles guide everything we do at Ghost Guild.
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8"> <div class="space-y-6 text-lg text-gray-700 dark:text-gray-300">
<!-- Cooperation --> <p>
<div class="text-center"> <strong>Movement between circles is fluid.</strong> As you move
<div class="mb-6"> along in your journey, you can shift circles anytime. Just let us
<h3 class="text-xl font-semibold mb-4 text-gray-900 dark:text-white"> know.
Cooperation
</h3>
<div class="space-y-2">
<div class="h-1 bg-blue-500 rounded-full w-full" />
<div class="h-1 bg-blue-400 rounded-full w-3/4 mx-auto" />
<div class="h-1 bg-blue-300 rounded-full w-1/2 mx-auto" />
</div>
</div>
<p class="text-gray-600 dark:text-gray-400 leading-relaxed">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. We believe in the power of working together, sharing knowledge, and supporting each other's success rather than competing for scarce resources.
</p> </p>
</div>
<!-- Sustainability --> <p>
<div class="text-center"> <strong>Your contribution is separate from your circle.</strong>
<div class="mb-6"> Whether you contribute $0 or $50+/month, you get full access to
<h3 class="text-xl font-semibold mb-4 text-gray-900 dark:text-white"> everything. Choose based on your financial capacity, not your
Sustainability circle.
</h3>
<div class="space-y-2">
<div class="h-1 bg-emerald-500 rounded-full w-full" />
<div class="h-1 bg-emerald-400 rounded-full w-5/6 mx-auto" />
<div class="h-1 bg-emerald-300 rounded-full w-2/3 mx-auto" />
</div>
</div>
<p class="text-gray-600 dark:text-gray-400 leading-relaxed">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. We prioritize long-term thinking, environmental responsibility, and creating work cultures that support developer wellbeing and work-life balance.
</p> </p>
</div>
<!-- Democracy --> <p>
<div class="text-center"> <strong>Not sure which circle?</strong> Start with Community - you
<div class="mb-6"> can always move. Or email us and we'll chat about what makes sense
<h3 class="text-xl font-semibold mb-4 text-gray-900 dark:text-white"> for you.
Democracy
</h3>
<div class="space-y-2">
<div class="h-1 bg-purple-500 rounded-full w-full" />
<div class="h-1 bg-purple-400 rounded-full w-4/5 mx-auto" />
<div class="h-1 bg-purple-300 rounded-full w-3/5 mx-auto" />
</div>
</div>
<p class="text-gray-600 dark:text-gray-400 leading-relaxed">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. We advocate for democratic decision-making processes where all workers have a voice in shaping their workplace and the direction of their studio.
</p> </p>
</div> </div>
<!-- Transparency -->
<div class="text-center">
<div class="mb-6">
<h3 class="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
Transparency
</h3>
<div class="space-y-2">
<div class="h-1 bg-cyan-500 rounded-full w-full" />
<div class="h-1 bg-cyan-400 rounded-full w-3/5 mx-auto" />
<div class="h-1 bg-cyan-300 rounded-full w-4/5 mx-auto" />
</div>
</div>
<p class="text-gray-600 dark:text-gray-400 leading-relaxed">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. We promote open communication, shared financial information, and clear decision-making processes that build trust and accountability.
</p>
</div>
<!-- Innovation -->
<div class="text-center">
<div class="mb-6">
<h3 class="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
Innovation
</h3>
<div class="space-y-2">
<div class="h-1 bg-orange-500 rounded-full w-full" />
<div class="h-1 bg-orange-400 rounded-full w-2/3 mx-auto" />
<div class="h-1 bg-orange-300 rounded-full w-5/6 mx-auto" />
</div>
</div>
<p class="text-gray-600 dark:text-gray-400 leading-relaxed">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. We encourage creative risk-taking, experimentation with new business models, and innovative approaches to both game development and studio management.
</p>
</div>
<!-- Solidarity -->
<div class="text-center">
<div class="mb-6">
<h3 class="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
Solidarity
</h3>
<div class="space-y-2">
<div class="h-1 bg-red-500 rounded-full w-full" />
<div class="h-1 bg-red-400 rounded-full w-4/6 mx-auto" />
<div class="h-1 bg-red-300 rounded-full w-3/4 mx-auto" />
</div>
</div>
<p class="text-gray-600 dark:text-gray-400 leading-relaxed">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. We stand together in mutual support, recognizing that our individual success is connected to the wellbeing of our entire community.
</p>
</div>
</div>
<!-- Call to Action -->
<div class="mt-16 text-center">
<div class="bg-blue-50 dark:bg-blue-900/20 rounded-2xl p-8 border border-blue-200 dark:border-blue-800 max-w-2xl mx-auto">
<h3 class="text-xl font-semibold mb-4 text-blue-600 dark:text-blue-400">
Join Our Community
</h3>
<p class="text-gray-600 dark:text-gray-300 mb-6">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ready to be part of the cooperative game development movement?
</p>
<UButton
to="/join"
size="lg"
color="primary"
class="px-8"
>
Get Started
</UButton>
</div>
</div> </div>
</UContainer> </UContainer>
</section> </section>
@ -309,4 +281,4 @@
<script setup> <script setup>
// No specific logic needed for the about page at this time // No specific logic needed for the about page at this time
</script> </script>

View file

@ -139,12 +139,11 @@
<label class="block text-sm font-medium text-gray-700 mb-2"> <label class="block text-sm font-medium text-gray-700 mb-2">
Start Date & Time <span class="text-red-500">*</span> Start Date & Time <span class="text-red-500">*</span>
</label> </label>
<input <NaturalDateInput
v-model="eventForm.startDate" v-model="eventForm.startDate"
type="datetime-local" placeholder="e.g., 'tomorrow at 3pm', 'next Friday at 9am'"
required :required="true"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent" :input-class="{ 'border-red-300 focus:ring-red-500': fieldErrors.startDate }"
:class="{ 'border-red-300 focus:ring-red-500': fieldErrors.startDate }"
/> />
<p v-if="fieldErrors.startDate" class="mt-1 text-sm text-red-600">{{ fieldErrors.startDate }}</p> <p v-if="fieldErrors.startDate" class="mt-1 text-sm text-red-600">{{ fieldErrors.startDate }}</p>
</div> </div>
@ -153,12 +152,11 @@
<label class="block text-sm font-medium text-gray-700 mb-2"> <label class="block text-sm font-medium text-gray-700 mb-2">
End Date & Time <span class="text-red-500">*</span> End Date & Time <span class="text-red-500">*</span>
</label> </label>
<input <NaturalDateInput
v-model="eventForm.endDate" v-model="eventForm.endDate"
type="datetime-local" placeholder="e.g., 'tomorrow at 5pm', 'next Friday at 11am'"
required :required="true"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent" :input-class="{ 'border-red-300 focus:ring-red-500': fieldErrors.endDate }"
:class="{ 'border-red-300 focus:ring-red-500': fieldErrors.endDate }"
/> />
<p v-if="fieldErrors.endDate" class="mt-1 text-sm text-red-600">{{ fieldErrors.endDate }}</p> <p v-if="fieldErrors.endDate" class="mt-1 text-sm text-red-600">{{ fieldErrors.endDate }}</p>
</div> </div>
@ -177,11 +175,9 @@
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Registration Deadline</label> <label class="block text-sm font-medium text-gray-700 mb-2">Registration Deadline</label>
<input <NaturalDateInput
v-model="eventForm.registrationDeadline" v-model="eventForm.registrationDeadline"
type="datetime-local" placeholder="e.g., 'tomorrow at noon', '1 hour before event'"
placeholder="Optional"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/> />
<p class="mt-1 text-sm text-gray-500">When should registration close? (optional)</p> <p class="mt-1 text-sm text-gray-500">When should registration close? (optional)</p>
</div> </div>
@ -236,6 +232,208 @@
</div> </div>
</div> </div>
<!-- Ticketing -->
<div class="mb-8">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Ticketing</h2>
<div class="space-y-6">
<label class="flex items-start">
<input
v-model="eventForm.tickets.enabled"
type="checkbox"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500 mt-1"
/>
<div class="ml-3">
<span class="text-sm font-medium text-gray-700">Enable Ticketing</span>
<p class="text-xs text-gray-500">Allow ticket sales for this event</p>
</div>
</label>
<div v-if="eventForm.tickets.enabled" class="ml-6 space-y-4 p-4 bg-gray-50 rounded-lg">
<label class="flex items-start">
<input
v-model="eventForm.tickets.public.available"
type="checkbox"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500 mt-1"
/>
<div class="ml-3">
<span class="text-sm font-medium text-gray-700">Public Tickets Available</span>
<p class="text-xs text-gray-500">Allow non-members to purchase tickets</p>
</div>
</label>
<div v-if="eventForm.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-gray-700 mb-2">Ticket Name</label>
<input
v-model="eventForm.tickets.public.name"
type="text"
placeholder="e.g., General Admission"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Price (CAD)</label>
<input
v-model="eventForm.tickets.public.price"
type="number"
min="0"
step="0.01"
placeholder="0.00"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<p class="mt-1 text-xs text-gray-500">Set to 0 for free public events</p>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Ticket Description</label>
<textarea
v-model="eventForm.tickets.public.description"
placeholder="What's included with this ticket..."
rows="2"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
></textarea>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Quantity Available</label>
<input
v-model="eventForm.tickets.public.quantity"
type="number"
min="1"
placeholder="Leave blank for unlimited"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Early Bird Price (Optional)</label>
<input
v-model="eventForm.tickets.public.earlyBirdPrice"
type="number"
min="0"
step="0.01"
placeholder="0.00"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
</div>
<div v-if="eventForm.tickets.public.earlyBirdPrice > 0">
<label class="block text-sm font-medium text-gray-700 mb-2">Early Bird Deadline</label>
<div class="md:w-1/2">
<NaturalDateInput
v-model="eventForm.tickets.public.earlyBirdDeadline"
placeholder="e.g., '1 week before event', 'next Monday'"
/>
</div>
<p class="mt-1 text-xs text-gray-500">Price increases to regular price after this date</p>
</div>
</div>
</div>
<div class="p-3 bg-blue-50 rounded-lg">
<p class="text-sm text-blue-700">
<strong>Note:</strong> Members always get free access to all events regardless of ticket settings.
</p>
</div>
</div>
</div>
<!-- Series Management -->
<div class="mb-8">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Series Management</h2>
<div class="space-y-4">
<label class="flex items-start">
<input
v-model="eventForm.series.isSeriesEvent"
type="checkbox"
class="rounded border-gray-300 text-purple-600 focus:ring-purple-500 mt-1"
/>
<div class="ml-3">
<span class="text-sm font-medium text-gray-700">Part of Event Series</span>
<p class="text-xs text-gray-500">This event is part of a multi-event series</p>
</div>
</label>
<div v-if="eventForm.series.isSeriesEvent" class="ml-6 space-y-4 p-4 bg-purple-50 rounded-lg border border-purple-200">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Select Series <span class="text-red-500">*</span>
</label>
<div class="flex gap-2">
<select
v-model="selectedSeriesId"
@change="onSeriesSelect"
class="flex-1 border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
<option value="">Choose existing series or create new...</option>
<option v-for="series in availableSeries" :key="series.id" :value="series.id">
{{ series.title }} ({{ series.eventCount || 0 }} events)
</option>
</select>
<NuxtLink
to="/admin/series/create"
class="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 text-sm font-medium whitespace-nowrap"
>
New Series
</NuxtLink>
</div>
<p class="text-xs text-gray-500 mt-1">
Select an existing series or create a new one
</p>
</div>
<div v-if="selectedSeriesId || eventForm.series.id" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Series Title <span class="text-red-500">*</span>
</label>
<input
v-model="eventForm.series.title"
type="text"
placeholder="e.g., Cooperative Game Development Fundamentals"
required
:readonly="selectedSeriesId"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
:class="{ 'bg-gray-100': selectedSeriesId }"
/>
<p class="text-xs text-gray-500 mt-1">{{ selectedSeriesId ? 'From selected series' : 'Descriptive name for the entire series' }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Series Description <span class="text-red-500">*</span>
</label>
<textarea
v-model="eventForm.series.description"
placeholder="Describe what the series covers and its goals"
required
rows="3"
:readonly="selectedSeriesId"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
:class="{ 'bg-gray-100': selectedSeriesId }"
></textarea>
<p class="text-xs text-gray-500 mt-1">{{ selectedSeriesId ? 'From selected series' : 'Describe what the series covers and its goals' }}</p>
</div>
<div v-if="selectedSeriesId" class="p-3 bg-blue-50 rounded-lg">
<p class="text-sm text-blue-700">
<strong>Note:</strong> This event will be added to the existing "{{ eventForm.series.title }}" series.
</p>
</div>
</div>
</div>
</div>
</div>
<!-- Event Settings --> <!-- Event Settings -->
<div class="mb-8"> <div class="mb-8">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Event Settings</h2> <h2 class="text-lg font-semibold text-gray-900 mb-4">Event Settings</h2>
@ -354,6 +552,8 @@ const editingEvent = ref(null)
const showSuccessMessage = ref(false) const showSuccessMessage = ref(false)
const formErrors = ref([]) const formErrors = ref([])
const fieldErrors = ref({}) const fieldErrors = ref({})
const selectedSeriesId = ref('')
const availableSeries = ref([])
const eventForm = reactive({ const eventForm = reactive({
title: '', title: '',
@ -371,9 +571,54 @@ const eventForm = reactive({
targetCircles: [], targetCircles: [],
maxAttendees: '', maxAttendees: '',
registrationRequired: false, registrationRequired: false,
registrationDeadline: '' registrationDeadline: '',
tickets: {
enabled: false,
public: {
available: false,
name: 'Public Ticket',
description: '',
price: 0,
quantity: null,
earlyBirdPrice: null,
earlyBirdDeadline: ''
}
},
series: {
isSeriesEvent: false,
id: '',
title: '',
description: ''
}
}) })
// Load available series
onMounted(async () => {
try {
const response = await $fetch('/api/admin/series')
availableSeries.value = response
} catch (error) {
console.error('Failed to load series:', error)
}
})
// Handle series selection
const onSeriesSelect = () => {
if (selectedSeriesId.value) {
const series = availableSeries.value.find(s => s.id === selectedSeriesId.value)
if (series) {
eventForm.series.id = series.id
eventForm.series.title = series.title
eventForm.series.description = series.description
}
} else {
// Reset series form when no series is selected
eventForm.series.id = ''
eventForm.series.title = ''
eventForm.series.description = ''
}
}
// Check if we're editing an event // Check if we're editing an event
if (route.query.edit) { if (route.query.edit) {
try { try {
@ -398,8 +643,30 @@ if (route.query.edit) {
targetCircles: event.targetCircles || [], targetCircles: event.targetCircles || [],
maxAttendees: event.maxAttendees || '', maxAttendees: event.maxAttendees || '',
registrationRequired: event.registrationRequired, registrationRequired: event.registrationRequired,
registrationDeadline: event.registrationDeadline ? new Date(event.registrationDeadline).toISOString().slice(0, 16) : '' registrationDeadline: event.registrationDeadline ? new Date(event.registrationDeadline).toISOString().slice(0, 16) : '',
tickets: event.tickets || {
enabled: false,
public: {
available: false,
name: 'Public Ticket',
description: '',
price: 0,
quantity: null,
earlyBirdPrice: null,
earlyBirdDeadline: ''
}
},
series: event.series || {
isSeriesEvent: false,
id: '',
title: '',
description: ''
}
}) })
// Handle early bird deadline formatting
if (event.tickets?.public?.earlyBirdDeadline) {
eventForm.tickets.public.earlyBirdDeadline = new Date(event.tickets.public.earlyBirdDeadline).toISOString().slice(0, 16)
}
} }
} catch (error) { } catch (error) {
console.error('Failed to load event for editing:', error) console.error('Failed to load event for editing:', error)
@ -420,6 +687,20 @@ if (route.query.duplicate && process.client) {
} }
} }
// Check if we're creating a series event
if (route.query.series && process.client) {
const seriesData = sessionStorage.getItem('seriesEventData')
if (seriesData) {
try {
const data = JSON.parse(seriesData)
Object.assign(eventForm, data)
sessionStorage.removeItem('seriesEventData')
} catch (error) {
console.error('Failed to load series event data:', error)
}
}
}
const validateForm = () => { const validateForm = () => {
formErrors.value = [] formErrors.value = []
fieldErrors.value = {} fieldErrors.value = {}
@ -491,6 +772,7 @@ const validateForm = () => {
return formErrors.value.length === 0 return formErrors.value.length === 0
} }
const saveEvent = async (redirect = true) => { const saveEvent = async (redirect = true) => {
if (!validateForm()) { if (!validateForm()) {
// Scroll to top to show errors // Scroll to top to show errors
@ -500,6 +782,14 @@ const saveEvent = async (redirect = true) => {
creating.value = true creating.value = true
try { try {
// If this is a series event and not using an existing series, create the standalone series first
if (eventForm.series.isSeriesEvent && selectedSeriesId.value) {
// Series will be handled by the selected series
} else if (eventForm.series.isSeriesEvent) {
// For now, series creation requires selecting an existing series
// Individual series creation is handled through the series management page
}
if (editingEvent.value) { if (editingEvent.value) {
await $fetch(`/api/admin/events/${editingEvent.value._id}`, { await $fetch(`/api/admin/events/${editingEvent.value._id}`, {
method: 'PUT', method: 'PUT',
@ -552,7 +842,25 @@ const saveAndCreateAnother = async () => {
targetCircles: [], targetCircles: [],
maxAttendees: '', maxAttendees: '',
registrationRequired: false, registrationRequired: false,
registrationDeadline: '' registrationDeadline: '',
tickets: {
enabled: false,
public: {
available: false,
name: 'Public Ticket',
description: '',
price: 0,
quantity: null,
earlyBirdPrice: null,
earlyBirdDeadline: ''
}
},
series: {
isSeriesEvent: false,
id: '',
title: '',
description: ''
}
}) })
// Clear any existing errors // Clear any existing errors

View file

@ -27,6 +27,11 @@
<option value="ongoing">Ongoing</option> <option value="ongoing">Ongoing</option>
<option value="past">Past</option> <option value="past">Past</option>
</select> </select>
<select v-model="seriesFilter" class="border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<option value="">All Events</option>
<option value="series-only">Series Events Only</option>
<option value="standalone-only">Standalone Only</option>
</select>
</div> </div>
<NuxtLink to="/admin/events/create" class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 inline-flex items-center"> <NuxtLink to="/admin/events/create" class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 inline-flex items-center">
<Icon name="heroicons:plus" class="w-4 h-4 mr-2" /> <Icon name="heroicons:plus" class="w-4 h-4 mr-2" />
@ -77,6 +82,14 @@
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="text-sm font-semibold text-gray-900 mb-1">{{ event.title }}</div> <div class="text-sm font-semibold text-gray-900 mb-1">{{ event.title }}</div>
<div class="text-sm text-gray-500 line-clamp-2">{{ event.description.substring(0, 100) }}...</div> <div class="text-sm text-gray-500 line-clamp-2">{{ event.description.substring(0, 100) }}...</div>
<div v-if="event.series?.isSeriesEvent" class="mt-2 mb-2">
<div class="inline-flex items-center gap-1 px-2 py-1 bg-purple-100 text-purple-700 text-xs font-medium rounded-full">
<div class="w-4 h-4 bg-purple-200 text-purple-700 rounded-full flex items-center justify-center text-xs font-bold">
{{ event.series.position }}
</div>
{{ event.series.title }}
</div>
</div>
<div class="flex items-center space-x-4 mt-2"> <div class="flex items-center space-x-4 mt-2">
<div v-if="event.membersOnly" class="flex items-center text-xs text-purple-600"> <div v-if="event.membersOnly" class="flex items-center text-xs text-purple-600">
<Icon name="heroicons:lock-closed" class="w-3 h-3 mr-1" /> <Icon name="heroicons:lock-closed" class="w-3 h-3 mr-1" />
@ -193,6 +206,7 @@ const { data: events, pending, error, refresh } = await useFetch("/api/admin/eve
const searchQuery = ref('') const searchQuery = ref('')
const typeFilter = ref('') const typeFilter = ref('')
const statusFilter = ref('') const statusFilter = ref('')
const seriesFilter = ref('')
const filteredEvents = computed(() => { const filteredEvents = computed(() => {
if (!events.value) return [] if (!events.value) return []
@ -207,7 +221,11 @@ const filteredEvents = computed(() => {
const eventStatus = getEventStatus(event) const eventStatus = getEventStatus(event)
const matchesStatus = !statusFilter.value || eventStatus.toLowerCase() === statusFilter.value const matchesStatus = !statusFilter.value || eventStatus.toLowerCase() === statusFilter.value
return matchesSearch && matchesType && matchesStatus const matchesSeries = !seriesFilter.value ||
(seriesFilter.value === 'series-only' && event.series?.isSeriesEvent) ||
(seriesFilter.value === 'standalone-only' && !event.series?.isSeriesEvent)
return matchesSearch && matchesType && matchesStatus && matchesSeries
}) })
}) })

View file

@ -0,0 +1,504 @@
<template>
<div>
<div class="bg-white border-b">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="py-6">
<h1 class="text-2xl font-bold text-gray-900">Series Management</h1>
<p class="text-gray-600">Manage event series and their relationships</p>
</div>
</div>
</div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Series Overview -->
<div class="mb-8">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="bg-white rounded-lg shadow p-6">
<div class="flex items-center">
<div class="p-3 bg-purple-100 rounded-full">
<Icon name="heroicons:squares-2x2" class="w-6 h-6 text-purple-600" />
</div>
<div class="ml-4">
<p class="text-sm text-gray-500">Active Series</p>
<p class="text-2xl font-semibold text-gray-900">{{ activeSeries.length }}</p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow p-6">
<div class="flex items-center">
<div class="p-3 bg-blue-100 rounded-full">
<Icon name="heroicons:calendar-days" class="w-6 h-6 text-blue-600" />
</div>
<div class="ml-4">
<p class="text-sm text-gray-500">Total Series Events</p>
<p class="text-2xl font-semibold text-gray-900">{{ totalSeriesEvents }}</p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow p-6">
<div class="flex items-center">
<div class="p-3 bg-green-100 rounded-full">
<Icon name="heroicons:chart-bar" class="w-6 h-6 text-green-600" />
</div>
<div class="ml-4">
<p class="text-sm text-gray-500">Avg Events/Series</p>
<p class="text-2xl font-semibold text-gray-900">
{{ activeSeries.length > 0 ? Math.round(totalSeriesEvents / activeSeries.length) : 0 }}
</p>
</div>
</div>
</div>
</div>
</div>
<!-- Actions Bar -->
<div class="mb-6 flex justify-between items-center">
<div class="flex gap-4 items-center">
<input
v-model="searchQuery"
placeholder="Search series..."
class="border border-gray-300 rounded-lg px-4 py-2 w-80 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
<select
v-model="statusFilter"
class="border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
<option value="">All Status</option>
<option value="active">Active</option>
<option value="upcoming">Upcoming</option>
<option value="completed">Completed</option>
</select>
</div>
<div class="flex gap-3">
<button
@click="showBulkModal = true"
class="bg-gray-600 text-white px-4 py-2 rounded-lg hover:bg-gray-700 inline-flex items-center"
>
<Icon name="heroicons:cog-6-tooth" class="w-4 h-4 mr-2" />
Bulk Operations
</button>
<NuxtLink
to="/admin/series/create"
class="bg-purple-600 text-white px-4 py-2 rounded-lg hover:bg-purple-700 inline-flex items-center"
>
<Icon name="heroicons:plus" class="w-4 h-4 mr-2" />
Create Series
</NuxtLink>
</div>
</div>
<!-- Series List -->
<div v-if="pending" class="text-center py-12">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-500 mx-auto mb-4"></div>
<p class="text-gray-600">Loading series...</p>
</div>
<div v-else-if="filteredSeries.length > 0" class="space-y-6">
<div
v-for="series in filteredSeries"
:key="series.id"
class="bg-white rounded-lg shadow overflow-hidden"
>
<!-- Series Header -->
<div class="px-6 py-4 bg-gray-50 border-b border-gray-200">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div :class="[
'inline-flex items-center px-3 py-1 rounded-full text-xs font-medium',
getSeriesTypeBadgeClass(series.type)
]">
{{ formatSeriesType(series.type) }}
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900">{{ series.title }}</h3>
<p class="text-sm text-gray-600">{{ series.description }}</p>
</div>
</div>
<div class="flex items-center gap-3">
<span :class="[
'inline-flex items-center px-2 py-1 rounded-full text-xs font-medium',
series.status === 'active' ? 'bg-green-100 text-green-700' :
series.status === 'upcoming' ? 'bg-blue-100 text-blue-700' :
'bg-gray-100 text-gray-700'
]">
{{ series.status }}
</span>
<span class="text-sm text-gray-500">
{{ series.eventCount }} events
</span>
</div>
</div>
</div>
<!-- Series Events -->
<div class="divide-y divide-gray-200">
<div
v-for="event in series.events"
:key="event.id"
class="px-6 py-4 hover:bg-gray-50"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="w-8 h-8 bg-purple-100 text-purple-600 rounded-full flex items-center justify-center text-sm font-semibold">
{{ event.series?.position || '?' }}
</div>
<div>
<h4 class="text-sm font-medium text-gray-900">{{ event.title }}</h4>
<p class="text-xs text-gray-500">{{ formatEventDate(event.startDate) }}</p>
</div>
</div>
<div class="flex items-center gap-2">
<span :class="[
'inline-flex items-center px-2 py-1 rounded-full text-xs font-medium',
getEventStatusClass(event)
]">
{{ getEventStatus(event) }}
</span>
<div class="flex gap-1">
<NuxtLink
:to="`/events/${event.slug || event.id}`"
class="p-1 text-gray-400 hover:text-gray-600 rounded"
title="View Event"
>
<Icon name="heroicons:eye" class="w-4 h-4" />
</NuxtLink>
<button
@click="editEvent(event)"
class="p-1 text-gray-400 hover:text-purple-600 rounded"
title="Edit Event"
>
<Icon name="heroicons:pencil-square" class="w-4 h-4" />
</button>
<button
@click="removeFromSeries(event)"
class="p-1 text-gray-400 hover:text-red-600 rounded"
title="Remove from Series"
>
<Icon name="heroicons:x-mark" class="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Series Actions -->
<div class="px-6 py-3 bg-gray-50 border-t border-gray-200">
<div class="flex justify-between items-center">
<div class="text-sm text-gray-500">
{{ formatDateRange(series.startDate, series.endDate) }}
</div>
<div class="flex gap-2">
<button
@click="addEventToSeries(series)"
class="text-sm text-purple-600 hover:text-purple-700 font-medium"
>
Add Event
</button>
<button
@click="duplicateSeries(series)"
class="text-sm text-blue-600 hover:text-blue-700 font-medium"
>
Duplicate Series
</button>
<button
@click="deleteSeries(series)"
class="text-sm text-red-600 hover:text-red-700 font-medium"
>
Delete Series
</button>
</div>
</div>
</div>
</div>
</div>
<div v-else class="text-center py-12 bg-white rounded-lg shadow">
<Icon name="heroicons:squares-2x2" class="w-12 h-12 text-gray-400 mx-auto mb-3" />
<p class="text-gray-600">No event series found</p>
<p class="text-sm text-gray-500 mt-2">Create events and group them into series to get started</p>
</div>
</div>
<!-- Bulk Operations Modal -->
<div v-if="showBulkModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center p-4 z-50">
<div class="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div class="px-6 py-4 border-b border-gray-200">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900">Bulk Series Operations</h3>
<button @click="showBulkModal = false" class="text-gray-400 hover:text-gray-600">
<Icon name="heroicons:x-mark" class="w-5 h-5" />
</button>
</div>
</div>
<div class="p-6 space-y-6">
<div>
<h4 class="text-sm font-medium text-gray-900 mb-3">Series Management Tools</h4>
<div class="space-y-3">
<button
@click="reorderAllSeries"
class="w-full text-left p-3 border border-gray-200 rounded-lg hover:bg-gray-50"
>
<div class="flex items-center">
<Icon name="heroicons:arrows-up-down" class="w-5 h-5 text-gray-400 mr-3" />
<div>
<p class="text-sm font-medium text-gray-900">Auto-Reorder Series</p>
<p class="text-xs text-gray-500">Fix position numbers based on event dates</p>
</div>
</div>
</button>
<button
@click="validateAllSeries"
class="w-full text-left p-3 border border-gray-200 rounded-lg hover:bg-gray-50"
>
<div class="flex items-center">
<Icon name="heroicons:check-circle" class="w-5 h-5 text-gray-400 mr-3" />
<div>
<p class="text-sm font-medium text-gray-900">Validate Series Data</p>
<p class="text-xs text-gray-500">Check for consistency issues</p>
</div>
</div>
</button>
<button
@click="exportSeriesData"
class="w-full text-left p-3 border border-gray-200 rounded-lg hover:bg-gray-50"
>
<div class="flex items-center">
<Icon name="heroicons:document-arrow-down" class="w-5 h-5 text-gray-400 mr-3" />
<div>
<p class="text-sm font-medium text-gray-900">Export Series Data</p>
<p class="text-xs text-gray-500">Download series information as JSON</p>
</div>
</div>
</button>
</div>
</div>
</div>
<div class="px-6 py-4 border-t border-gray-200 flex justify-end">
<button
@click="showBulkModal = false"
class="px-4 py-2 text-gray-600 hover:text-gray-700"
>
Close
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
definePageMeta({
layout: 'admin'
})
const showBulkModal = ref(false)
const searchQuery = ref('')
const statusFilter = ref('')
// Fetch series data
const { data: seriesData, pending, refresh } = await useFetch('/api/admin/series')
// Computed properties
const activeSeries = computed(() => {
if (!seriesData.value) return []
return seriesData.value
})
const totalSeriesEvents = computed(() => {
return activeSeries.value.reduce((sum, series) => sum + (series.eventCount || 0), 0)
})
const filteredSeries = computed(() => {
if (!activeSeries.value) return []
return activeSeries.value.filter(series => {
const matchesSearch = !searchQuery.value ||
series.title.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
series.description.toLowerCase().includes(searchQuery.value.toLowerCase())
const matchesStatus = !statusFilter.value || series.status === statusFilter.value
return matchesSearch && matchesStatus
})
})
// Helper functions
const formatSeriesType = (type) => {
const types = {
'workshop_series': 'Workshop Series',
'recurring_meetup': 'Recurring Meetup',
'multi_day': 'Multi-Day Event',
'course': 'Course',
'tournament': 'Tournament'
}
return types[type] || type
}
const getSeriesTypeBadgeClass = (type) => {
const classes = {
'workshop_series': 'bg-emerald-100 text-emerald-700',
'recurring_meetup': 'bg-blue-100 text-blue-700',
'multi_day': 'bg-purple-100 text-purple-700',
'course': 'bg-amber-100 text-amber-700',
'tournament': 'bg-red-100 text-red-700'
}
return classes[type] || 'bg-gray-100 text-gray-700'
}
const formatEventDate = (date) => {
return new Date(date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
})
}
const formatDateRange = (startDate, endDate) => {
if (!startDate || !endDate) return 'No dates'
const start = new Date(startDate)
const end = new Date(endDate)
const formatter = new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric'
})
return `${formatter.format(start)} - ${formatter.format(end)}`
}
const getEventStatus = (event) => {
const now = new Date()
const startDate = new Date(event.startDate)
const endDate = new Date(event.endDate)
if (now < startDate) return 'Upcoming'
if (now >= startDate && now <= endDate) return 'Ongoing'
return 'Completed'
}
const getEventStatusClass = (event) => {
const status = getEventStatus(event)
const classes = {
'Upcoming': 'bg-blue-100 text-blue-700',
'Ongoing': 'bg-green-100 text-green-700',
'Completed': 'bg-gray-100 text-gray-700'
}
return classes[status] || 'bg-gray-100 text-gray-700'
}
// Actions
const editEvent = (event) => {
navigateTo(`/admin/events/create?edit=${event.id}`)
}
const removeFromSeries = async (event) => {
if (!confirm(`Remove "${event.title}" from its series?`)) return
try {
await $fetch(`/api/admin/events/${event.id}`, {
method: 'PUT',
body: {
...event,
series: {
isSeriesEvent: false,
id: '',
title: '',
description: '',
type: 'workshop_series',
position: 1,
totalEvents: null
}
}
})
await refresh()
} catch (error) {
console.error('Failed to remove event from series:', error)
alert('Failed to remove event from series')
}
}
const addEventToSeries = (series) => {
// Navigate to create page with series pre-filled
const seriesData = {
series: {
isSeriesEvent: true,
id: series.id,
title: series.title,
description: series.description,
type: series.type,
position: (series.eventCount || 0) + 1,
totalEvents: series.totalEvents
}
}
sessionStorage.setItem('seriesEventData', JSON.stringify(seriesData))
navigateTo('/admin/events/create?series=true')
}
const duplicateSeries = (series) => {
// TODO: Implement series duplication
alert('Series duplication coming soon!')
}
const deleteSeries = async (series) => {
if (!confirm(`Delete the entire "${series.title}" series? This will remove the series relationship from all ${series.eventCount} events.`)) return
try {
// Update all events to remove series relationship
for (const event of series.events) {
await $fetch(`/api/admin/events/${event.id}`, {
method: 'PUT',
body: {
...event,
series: {
isSeriesEvent: false,
id: '',
title: '',
description: '',
type: 'workshop_series',
position: 1,
totalEvents: null
}
}
})
}
await refresh()
alert('Series deleted and events converted to standalone events')
} catch (error) {
console.error('Failed to delete series:', error)
alert('Failed to delete series')
}
}
// Bulk operations
const reorderAllSeries = async () => {
// TODO: Implement auto-reordering
alert('Auto-reorder feature coming soon!')
}
const validateAllSeries = async () => {
// TODO: Implement validation
alert('Validation feature coming soon!')
}
const exportSeriesData = () => {
const dataStr = JSON.stringify(activeSeries.value, null, 2)
const dataBlob = new Blob([dataStr], { type: 'application/json' })
const url = URL.createObjectURL(dataBlob)
const link = document.createElement('a')
link.href = url
link.download = 'event-series-data.json'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
</script>

View file

@ -0,0 +1,268 @@
<template>
<div>
<div class="bg-white border-b">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="py-6">
<div class="flex items-center gap-4 mb-2">
<NuxtLink to="/admin/series-management" class="text-gray-500 hover:text-gray-700">
<Icon name="heroicons:arrow-left" class="w-5 h-5" />
</NuxtLink>
<h1 class="text-2xl font-bold text-gray-900">Create New Series</h1>
</div>
<p class="text-gray-600">Create a new event series to group related events together</p>
</div>
</div>
</div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Error Summary -->
<div v-if="formErrors.length > 0" class="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
<div class="flex">
<Icon name="heroicons:exclamation-circle" class="w-5 h-5 text-red-500 mr-3 mt-0.5" />
<div>
<h3 class="text-sm font-medium text-red-800 mb-2">Please fix the following errors:</h3>
<ul class="text-sm text-red-700 space-y-1">
<li v-for="error in formErrors" :key="error"> {{ error }}</li>
</ul>
</div>
</div>
</div>
<!-- Success Message -->
<div v-if="showSuccessMessage" class="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg">
<div class="flex">
<Icon name="heroicons:check-circle" class="w-5 h-5 text-green-500 mr-3 mt-0.5" />
<div>
<h3 class="text-sm font-medium text-green-800">Series created successfully!</h3>
</div>
</div>
</div>
<form @submit.prevent="createSeries">
<!-- Series Information -->
<div class="mb-8">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Series Information</h2>
<div class="grid grid-cols-1 gap-6">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Series Title <span class="text-red-500">*</span>
</label>
<input
v-model="seriesForm.title"
type="text"
placeholder="e.g., Cooperative Game Development Fundamentals"
required
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
:class="{ 'border-red-300 focus:ring-red-500': fieldErrors.title }"
@input="generateSlugFromTitle"
/>
<p v-if="fieldErrors.title" class="mt-1 text-sm text-red-600">{{ fieldErrors.title }}</p>
</div>
<div v-if="generatedSlug">
<label class="block text-sm font-medium text-gray-700 mb-2">Generated Series ID</label>
<div class="w-full bg-gray-100 border border-gray-300 rounded-lg px-3 py-2 text-gray-700 font-mono text-sm">
{{ generatedSlug }}
</div>
<p class="mt-1 text-sm text-gray-500">
This unique identifier will be automatically generated from your title
</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Series Description <span class="text-red-500">*</span>
</label>
<textarea
v-model="seriesForm.description"
placeholder="Describe what the series covers and its goals"
required
rows="4"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
:class="{ 'border-red-300 focus:ring-red-500': fieldErrors.description }"
></textarea>
<p v-if="fieldErrors.description" class="mt-1 text-sm text-red-600">{{ fieldErrors.description }}</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Series Type</label>
<select
v-model="seriesForm.type"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
<option value="workshop_series">Workshop Series</option>
<option value="recurring_meetup">Recurring Meetup</option>
<option value="multi_day">Multi-Day Event</option>
<option value="course">Course</option>
<option value="tournament">Tournament</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Total Events Planned</label>
<input
v-model.number="seriesForm.totalEvents"
type="number"
min="1"
placeholder="e.g., 4"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
<p class="text-sm text-gray-500 mt-1">How many events will be in this series? (optional)</p>
</div>
</div>
</div>
</div>
<!-- Form Actions -->
<div class="flex justify-between items-center pt-6 border-t border-gray-200">
<NuxtLink
to="/admin/series-management"
class="px-4 py-2 text-gray-600 hover:text-gray-900 font-medium"
>
Cancel
</NuxtLink>
<div class="flex gap-3">
<button
type="button"
@click="createAndAddEvent"
:disabled="creating"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed font-medium"
>
{{ creating ? 'Creating...' : 'Create & Add Event' }}
</button>
<button
type="submit"
:disabled="creating"
class="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed font-medium"
>
{{ creating ? 'Creating...' : 'Create Series' }}
</button>
</div>
</div>
</form>
</div>
</div>
</template>
<script setup>
definePageMeta({
layout: 'admin'
})
const router = useRouter()
const creating = ref(false)
const showSuccessMessage = ref(false)
const formErrors = ref([])
const fieldErrors = ref({})
const generatedSlug = ref('')
const seriesForm = reactive({
title: '',
description: '',
type: 'workshop_series',
totalEvents: null
})
// Generate slug from title
const generateSlug = (text) => {
return text
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s-]/g, '') // Remove special characters except spaces and dashes
.replace(/\s+/g, '-') // Replace spaces with dashes
.replace(/-+/g, '-') // Replace multiple dashes with single dash
.replace(/^-+|-+$/g, '') // Remove leading/trailing dashes
}
const generateSlugFromTitle = () => {
if (seriesForm.title) {
generatedSlug.value = generateSlug(seriesForm.title)
} else {
generatedSlug.value = ''
}
}
const validateForm = () => {
formErrors.value = []
fieldErrors.value = {}
if (!seriesForm.title.trim()) {
formErrors.value.push('Series title is required')
fieldErrors.value.title = 'Please enter a series title'
}
if (!seriesForm.description.trim()) {
formErrors.value.push('Series description is required')
fieldErrors.value.description = 'Please provide a description for the series'
}
if (!generatedSlug.value) {
formErrors.value.push('Series title must generate a valid ID')
fieldErrors.value.title = 'Please enter a title that can generate a valid series ID'
}
return formErrors.value.length === 0
}
const createSeries = async (redirectAfter = true) => {
if (!validateForm()) {
window.scrollTo({ top: 0, behavior: 'smooth' })
return false
}
creating.value = true
try {
const response = await $fetch('/api/admin/series', {
method: 'POST',
body: {
...seriesForm,
id: generatedSlug.value
}
})
showSuccessMessage.value = true
setTimeout(() => { showSuccessMessage.value = false }, 5000)
if (redirectAfter) {
setTimeout(() => {
router.push('/admin/series-management')
}, 1500)
}
return response.data
} catch (error) {
console.error('Failed to create series:', error)
formErrors.value = [`Failed to create series: ${error.data?.statusMessage || error.message}`]
window.scrollTo({ top: 0, behavior: 'smooth' })
return false
} finally {
creating.value = false
}
}
const createAndAddEvent = async () => {
const series = await createSeries(false)
if (series) {
// Navigate to event creation with series pre-filled
const seriesData = {
series: {
isSeriesEvent: true,
id: generatedSlug.value,
title: seriesForm.title,
description: seriesForm.description,
type: seriesForm.type,
position: 1,
totalEvents: seriesForm.totalEvents
}
}
sessionStorage.setItem('seriesEventData', JSON.stringify(seriesData))
router.push('/admin/events/create?series=true')
}
}
</script>

View file

@ -1,322 +1,204 @@
<template> <template>
<div> <div class="relative">
<!-- Page Header --> <!-- Hero Section -->
<PageHeader <section class="mb-24">
title="Contact" <div class="relative">
subtitle="Get in touch with us. We're here to help and answer any questions you might have about Ghost Guild." <h1
theme="blue" class="text-6xl md:text-8xl font-bold text-stone-100 ethereal-text leading-tight mb-8"
size="large" >
/> Get in Touch
</h1>
<div class="mb-16">
<p class="text-stone-100 text-lg max-w-md">
We'd be happy to answer any questions<br />
you might have about Ghost Guild
</p>
</div>
</div>
</section>
<!-- Contact Form --> <!-- Contact Form -->
<section class="py-20 bg-white dark:bg-gray-900"> <section class="mb-32 relative">
<UContainer class="max-w-4xl"> <div class="mb-12">
<div class="text-center mb-12"> <h2 class="text-3xl font-light text-stone-200 mb-4">
<h2 class="text-3xl font-bold text-blue-600 dark:text-blue-400 mb-4"> Send us a message (or email hello@ghostguild.org)
Contact Form </h2>
</h2> </div>
<p class="text-gray-600 dark:text-gray-300">
Send us a message and we'll get back to you as soon as possible
</p>
</div>
<div class="bg-white dark:bg-gray-800 rounded-2xl p-8 shadow-xl border border-blue-200 dark:border-blue-800"> <div class="border border-ghost-700">
<UForm :state="form" class="space-y-6" @submit="handleSubmit"> <UForm :state="form" class="space-y-6" @submit="handleSubmit">
<!-- Name and Email Row --> <!-- Name and Email Row -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<UFormField label="Your Name" name="name" required> <UFormField label="Your Name" name="name" required>
<UInput <UInput
v-model="form.name" v-model="form.name"
placeholder="Enter your full name" placeholder="Enter your full name"
size="xl"
class="w-full"
/>
</UFormField>
<UFormField label="Email Address" name="email" required>
<UInput
v-model="form.email"
type="email"
size="xl"
class="w-full"
placeholder="your.email@example.com"
/>
</UFormField>
</div>
<!-- Subject -->
<UFormField label="Subject" name="subject" required>
<USelect
v-model="form.subject"
:options="subjectOptions"
placeholder="Select a subject"
size="xl" size="xl"
class="w-full" class="w-full"
/> />
</UFormField> </UFormField>
<!-- Message --> <UFormField label="Email Address" name="email" required>
<UFormField label="Message" name="message" required> <UInput
<UTextarea v-model="form.email"
v-model="form.message" type="email"
placeholder="Tell us how we can help you..."
:rows="6"
size="xl" size="xl"
class="w-full" class="w-full"
placeholder="your.email@example.com"
/> />
</UFormField> </UFormField>
<!-- Additional Options -->
<div class="flex items-center gap-4 pt-2">
<UCheckbox
id="newsletter"
v-model="form.newsletter"
label="Subscribe to our newsletter"
/>
</div>
<!-- Submit Button -->
<div class="flex justify-center pt-4">
<UButton
type="submit"
:loading="isSubmitting"
:disabled="!isFormValid"
size="xl"
class="px-12"
>
Send Message
</UButton>
</div>
</UForm>
<!-- Success/Error Messages -->
<div v-if="success" class="mt-6 p-4 bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800">
<p class="text-green-700 dark:text-green-300 text-center">
Thank you! Your message has been sent successfully. We'll get back to you soon.
</p>
</div> </div>
<div v-if="error" class="mt-6 p-4 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-800"> <!-- Subject -->
<p class="text-red-700 dark:text-red-300 text-center"> <UFormField label="Subject" name="subject" required>
{{ error }} <USelect
</p> v-model="form.subject"
</div> :options="subjectOptions"
</div> placeholder="Select a subject"
</UContainer> size="xl"
</section> class="w-full"
/>
</UFormField>
<!-- Support Information --> <!-- Message -->
<section class="py-20 bg-gray-50 dark:bg-gray-800"> <UFormField label="Message" name="message" required>
<UContainer> <UTextarea
<div class="text-center mb-12"> v-model="form.message"
<h2 class="text-3xl font-bold text-blue-600 dark:text-blue-400 mb-4"> placeholder="Tell us how we can help you..."
Support :rows="6"
</h2> size="xl"
<p class="text-gray-600 dark:text-gray-300 max-w-2xl mx-auto"> class="w-full"
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Here are various ways to get help and support. />
</UFormField>
<!-- Submit Button -->
<div class="pt-4">
<UButton
type="submit"
:loading="isSubmitting"
:disabled="!isFormValid"
size="xl"
class="px-12 border border-ghost-600 text-stone-200 hover:bg-ghost-800 hover:border-whisper-500 hover:ethereal-text transition-all duration-500"
>
Send Message
</UButton>
</div>
</UForm>
<!-- Success/Error Messages -->
<div
v-if="success"
class="mt-6 p-4 border border-whisper-600 bg-ghost-900/50"
>
<p class="text-whisper-300 text-center">
Thank you! Your message has been sent successfully. We'll get back
to you soon.
</p> </p>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"> <div v-if="error" class="mt-6 p-4 border border-ghost-700 bg-ghost-900">
<!-- Help Center --> <p class="text-stone-300 text-center">
<div class="bg-white dark:bg-gray-900 rounded-xl p-8 shadow-lg text-center"> {{ error }}
<div class="w-16 h-16 bg-blue-100 dark:bg-blue-900/30 rounded-full flex items-center justify-center mx-auto mb-6">
<div class="w-8 h-8 bg-blue-500 rounded flex items-center justify-center">
<span class="text-white text-sm font-bold">?</span>
</div>
</div>
<h3 class="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
Help Center
</h3>
<div class="space-y-2 mb-6">
<div class="h-1 bg-blue-500 rounded-full w-full" />
<div class="h-1 bg-blue-300 rounded-full w-4/5 mx-auto" />
<div class="h-1 bg-blue-200 rounded-full w-3/5 mx-auto" />
<div class="h-1 bg-blue-100 rounded-full w-2/5 mx-auto" />
</div>
<p class="text-gray-600 dark:text-gray-400 mb-6">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Browse our comprehensive help articles.
</p>
<UButton variant="outline" color="blue" size="sm">
Visit Help Center
</UButton>
</div>
<!-- Community Support -->
<div class="bg-white dark:bg-gray-900 rounded-xl p-8 shadow-lg text-center">
<div class="w-16 h-16 bg-emerald-100 dark:bg-emerald-900/30 rounded-full flex items-center justify-center mx-auto mb-6">
<div class="w-8 h-8 bg-emerald-500" style="clip-path: polygon(50% 0%, 0% 100%, 100% 100%)" />
</div>
<h3 class="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
Community Support
</h3>
<div class="space-y-2 mb-6">
<div class="h-1 bg-emerald-500 rounded-full w-full" />
<div class="h-1 bg-emerald-300 rounded-full w-5/6 mx-auto" />
<div class="h-1 bg-emerald-200 rounded-full w-4/6 mx-auto" />
<div class="h-1 bg-emerald-100 rounded-full w-3/6 mx-auto" />
</div>
<p class="text-gray-600 dark:text-gray-400 mb-6">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Connect with other members in our community.
</p>
<UButton variant="outline" color="emerald" size="sm">
Join Discord
</UButton>
</div>
<!-- Direct Support -->
<div class="bg-white dark:bg-gray-900 rounded-xl p-8 shadow-lg text-center md:col-span-2 lg:col-span-1">
<div class="w-16 h-16 bg-purple-100 dark:bg-purple-900/30 rounded-full flex items-center justify-center mx-auto mb-6">
<div class="w-8 h-8 bg-purple-500 rounded-full flex items-center justify-center">
<span class="text-white text-xs"></span>
</div>
</div>
<h3 class="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
Direct Support
</h3>
<div class="space-y-2 mb-6">
<div class="h-1 bg-purple-500 rounded-full w-full" />
<div class="h-1 bg-purple-300 rounded-full w-3/4 mx-auto" />
<div class="h-1 bg-purple-200 rounded-full w-5/6 mx-auto" />
<div class="h-1 bg-purple-100 rounded-full w-1/2 mx-auto" />
</div>
<p class="text-gray-600 dark:text-gray-400 mb-6">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Get personalized help from our team.
</p>
<UButton variant="outline" color="purple" size="sm">
Email Us
</UButton>
</div>
</div>
<!-- Quick Contact Info -->
<div class="mt-16 grid grid-cols-1 md:grid-cols-3 gap-8 text-center">
<div>
<h4 class="font-semibold text-gray-900 dark:text-white mb-2">Email</h4>
<p class="text-gray-600 dark:text-gray-400">hello@ghostguild.org</p>
</div>
<div>
<h4 class="font-semibold text-gray-900 dark:text-white mb-2">Response Time</h4>
<p class="text-gray-600 dark:text-gray-400">Usually within 24 hours</p>
</div>
<div>
<h4 class="font-semibold text-gray-900 dark:text-white mb-2">Best For</h4>
<p class="text-gray-600 dark:text-gray-400">General inquiries & support</p>
</div>
</div>
</UContainer>
</section>
<!-- Send Message CTA -->
<section class="py-20 bg-blue-50 dark:bg-blue-900/20">
<UContainer>
<div class="text-center max-w-3xl mx-auto">
<h2 class="text-3xl font-bold text-blue-600 dark:text-blue-400 mb-8">
Send Message
</h2>
<div class="space-y-6 mb-8">
<div class="flex justify-center">
<div class="h-2 bg-blue-500 rounded-full w-64" />
</div>
<div class="flex justify-center">
<div class="h-2 bg-blue-300 rounded-full w-48" />
</div>
<div class="flex justify-center">
<div class="h-2 bg-blue-500 rounded-full w-32" />
</div>
</div>
<p class="text-lg text-gray-600 dark:text-gray-300 mb-8">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ready to get in touch? We're here to help.
</p> </p>
<UButton
@click="scrollToForm"
size="xl"
color="primary"
class="px-12 py-4"
>
Contact Us Now
</UButton>
</div> </div>
</UContainer> </div>
</section> </section>
</div> </div>
</template> </template>
<script setup> <script setup>
import { reactive, ref, computed } from 'vue' import { reactive, ref, computed } from "vue";
// Form state // Form state
const form = reactive({ const form = reactive({
name: '', name: "",
email: '', email: "",
subject: '', subject: "",
message: '', message: "",
newsletter: false newsletter: false,
}) });
// UI state // UI state
const isSubmitting = ref(false) const isSubmitting = ref(false);
const error = ref('') const error = ref("");
const success = ref(false) const success = ref(false);
// Subject options // Subject options
const subjectOptions = [ const subjectOptions = [
{ label: 'General Inquiry', value: 'general' }, { label: "General Inquiry", value: "general" },
{ label: 'Membership Questions', value: 'membership' }, { label: "Membership Questions", value: "membership" },
{ label: 'Technical Support', value: 'technical' }, { label: "Technical Support", value: "technical" },
{ label: 'Partnership Opportunities', value: 'partnership' }, { label: "Partnership Opportunities", value: "partnership" },
{ label: 'Press & Media', value: 'media' }, { label: "Press & Media", value: "media" },
{ label: 'Other', value: 'other' } { label: "Other", value: "other" },
] ];
// Support options
const supportOptions = [
{
title: "Help Center",
description:
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Browse our comprehensive help articles.",
details: ["Documentation", "FAQs", "Tutorials"],
},
{
title: "Community Support",
description:
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Connect with other members in our community.",
details: ["Discord", "Forums", "Slack"],
},
{
title: "Direct Support",
description:
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Get personalized help from our team.",
details: ["Email", "Priority response", "One-on-one assistance"],
},
];
// Form validation // Form validation
const isFormValid = computed(() => { const isFormValid = computed(() => {
return form.name && form.email && form.subject && form.message && form.message.length >= 10 return (
}) form.name &&
form.email &&
form.subject &&
form.message &&
form.message.length >= 10
);
});
// Form submission // Form submission
const handleSubmit = async () => { const handleSubmit = async () => {
if (isSubmitting.value) return if (isSubmitting.value) return;
isSubmitting.value = true isSubmitting.value = true;
error.value = '' error.value = "";
success.value = false success.value = false;
try { try {
// Simulate API call // Simulate API call
await new Promise(resolve => setTimeout(resolve, 1500)) await new Promise((resolve) => setTimeout(resolve, 1500));
// For now, just show success message // For now, just show success message
success.value = true success.value = true;
// Reset form after success // Reset form after success
setTimeout(() => { setTimeout(() => {
Object.assign(form, { Object.assign(form, {
name: '', name: "",
email: '', email: "",
subject: '', subject: "",
message: '', message: "",
newsletter: false newsletter: false,
}) });
success.value = false success.value = false;
}, 5000) }, 5000);
} catch (err) { } catch (err) {
console.error('Contact form error:', err) console.error("Contact form error:", err);
error.value = 'Sorry, there was an error sending your message. Please try again or contact us directly at hello@ghostguild.org.' error.value =
"Sorry, there was an error sending your message. Please try again or contact us directly at hello@ghostguild.org.";
} finally { } finally {
isSubmitting.value = false isSubmitting.value = false;
} }
} };
</script>
// Scroll to form function
const scrollToForm = () => {
const formSection = document.querySelector('form')
if (formSection) {
formSection.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}
</script>

View file

@ -1,83 +1,105 @@
<template> <template>
<div v-if="pending" class="min-h-screen flex items-center justify-center"> <div
v-if="pending"
class="min-h-screen bg-stone-900 flex items-center justify-center"
>
<div class="text-center"> <div class="text-center">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-4"></div> <div
<p class="text-gray-600 dark:text-gray-400">Loading event details...</p> class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-4"
></div>
<p class="text-stone-200">Loading event details...</p>
</div> </div>
</div> </div>
<div v-else-if="error" class="min-h-screen flex items-center justify-center"> <div
v-else-if="error"
class="min-h-screen bg-stone-900 flex items-center justify-center"
>
<div class="text-center"> <div class="text-center">
<Icon name="heroicons:exclamation-triangle" class="w-16 h-16 text-red-500 mx-auto mb-4" /> <Icon
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">Event Not Found</h2> name="heroicons:exclamation-triangle"
<p class="text-gray-600 dark:text-gray-400 mb-6">The event you're looking for doesn't exist.</p> class="w-16 h-16 text-red-500 mx-auto mb-4"
<NuxtLink to="/events" class="text-blue-600 dark:text-blue-400 hover:underline"> />
<h2 class="text-2xl font-bold text-stone-100 mb-2">Event Not Found</h2>
<p class="text-stone-300 mb-6">
The event you're looking for doesn't exist.
</p>
<NuxtLink to="/events" class="text-blue-400 hover:underline">
Back to Events Back to Events
</NuxtLink> </NuxtLink>
</div> </div>
</div> </div>
<div v-else> <div v-else>
<!-- Feature Image Header --> <!-- Feature Image Header -->
<div v-if="event.featureImage && (event.featureImage.publicId || event.featureImage.url)" class="relative h-96 overflow-hidden"> <div
<img v-if="
event.featureImage &&
(event.featureImage.publicId || event.featureImage.url)
"
class="relative h-96 overflow-hidden"
>
<img
:src="getImageUrl(event.featureImage)" :src="getImageUrl(event.featureImage)"
:alt="event.featureImage.alt || event.title" :alt="event.featureImage.alt || event.title"
class="w-full h-full object-cover" class="w-full h-full object-cover"
@error="handleImageError" @error="handleImageError"
/> />
<div class="absolute inset-0" style="background-color: rgba(0, 0, 0, 0.4);"></div> <div
class="absolute inset-0"
style="background-color: rgba(0, 0, 0, 0.4)"
></div>
<div class="absolute inset-0 flex items-center"> <div class="absolute inset-0 flex items-center">
<UContainer> <UContainer>
<div class="max-w-4xl"> <div class="max-w-4xl">
<h1 class="text-4xl md:text-5xl font-bold text-white mb-4"> <h1 class="text-4xl md:text-5xl font-bold text-white mb-4">
{{ event.title }} {{ event.title }}
</h1> </h1>
<p v-if="event.tagline" class="text-xl text-gray-200">
{{ event.tagline }}
</p>
</div> </div>
</UContainer> </UContainer>
</div> </div>
</div> </div>
<!-- Page Header (fallback when no image) --> <!-- Page Header (fallback when no image) -->
<PageHeader <PageHeader v-else :title="event.title" theme="blue" size="medium" />
v-else
:title="event.title"
:subtitle="event.tagline"
theme="blue"
size="medium"
/>
<!-- Event Details Section --> <!-- Event Details Section -->
<section class="py-16 bg-white dark:bg-gray-900"> <section class="py-16 bg-stone-900">
<UContainer> <UContainer>
<div class="max-w-4xl mx-auto"> <div class="max-w-4xl mx-auto">
<!-- Event Meta Info --> <!-- Event Meta Info -->
<div class="bg-blue-50 dark:bg-blue-900/20 rounded-xl p-6 mb-8 border border-blue-200 dark:border-blue-800"> <div class="bg-stone-800 rounded-xl p-6 mb-8 border border-stone-700">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6"> <div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="flex items-center space-x-3"> <div class="flex items-center space-x-3">
<Icon name="heroicons:calendar-days" class="w-6 h-6 text-blue-600 dark:text-blue-400" /> <Icon
name="heroicons:calendar-days"
class="w-6 h-6 text-blue-400"
/>
<div> <div>
<p class="text-sm text-gray-600 dark:text-gray-400">Date</p> <p class="text-sm text-stone-400">Date</p>
<p class="font-semibold text-gray-900 dark:text-white">{{ formatDate(event.startDate) }}</p> <p class="font-semibold text-stone-100">
{{ formatDate(event.startDate) }}
</p>
</div> </div>
</div> </div>
<div class="flex items-center space-x-3"> <div class="flex items-center space-x-3">
<Icon name="heroicons:clock" class="w-6 h-6 text-blue-600 dark:text-blue-400" /> <Icon name="heroicons:clock" class="w-6 h-6 text-blue-400" />
<div> <div>
<p class="text-sm text-gray-600 dark:text-gray-400">Time</p> <p class="text-sm text-stone-400">Time</p>
<p class="font-semibold text-gray-900 dark:text-white">{{ formatTime(event.startDate, event.endDate) }}</p> <p class="font-semibold text-stone-100">
{{ formatTime(event.startDate, event.endDate) }}
</p>
</div> </div>
</div> </div>
<div class="flex items-center space-x-3"> <div class="flex items-center space-x-3">
<Icon name="heroicons:map-pin" class="w-6 h-6 text-blue-600 dark:text-blue-400" /> <Icon name="heroicons:map-pin" class="w-6 h-6 text-blue-400" />
<div> <div>
<p class="text-sm text-gray-600 dark:text-gray-400">Location</p> <p class="text-sm text-stone-400">Location</p>
<p class="font-semibold text-gray-900 dark:text-white">{{ event.location }}</p> <p class="font-semibold text-stone-100">
{{ event.location }}
</p>
</div> </div>
</div> </div>
</div> </div>
@ -85,16 +107,22 @@
<!-- Event Cancelled Notice --> <!-- Event Cancelled Notice -->
<div v-if="event.isCancelled" class="mb-8"> <div v-if="event.isCancelled" class="mb-8">
<div class="p-6 bg-red-50 dark:bg-red-900/20 rounded-xl border border-red-200 dark:border-red-800"> <div class="p-6 bg-red-900/20 rounded-xl border border-red-800">
<div class="flex items-start"> <div class="flex items-start">
<Icon name="heroicons:exclamation-triangle" class="w-6 h-6 text-red-600 dark:text-red-400 mr-3 mt-0.5" /> <Icon
name="heroicons:exclamation-triangle"
class="w-6 h-6 text-red-400 mr-3 mt-0.5"
/>
<div> <div>
<h3 class="text-lg font-semibold text-red-700 dark:text-red-300 mb-2">Event Cancelled</h3> <h3 class="text-lg font-semibold text-red-300 mb-2">
<p class="text-red-600 dark:text-red-400" v-if="event.cancellationMessage"> Event Cancelled
</h3>
<p class="text-red-400" v-if="event.cancellationMessage">
{{ event.cancellationMessage }} {{ event.cancellationMessage }}
</p> </p>
<p class="text-red-600 dark:text-red-400" v-else> <p class="text-red-400" v-else>
This event has been cancelled. We apologize for any inconvenience. This event has been cancelled. We apologize for any
inconvenience.
</p> </p>
</div> </div>
</div> </div>
@ -103,24 +131,36 @@
<!-- Member-Only Badge --> <!-- Member-Only Badge -->
<div v-if="event.membersOnly" class="mb-8"> <div v-if="event.membersOnly" class="mb-8">
<div class="inline-flex items-center px-4 py-2 bg-purple-100 dark:bg-purple-900/30 rounded-full"> <div
<Icon name="heroicons:lock-closed" class="w-5 h-5 text-purple-600 dark:text-purple-400 mr-2" /> class="inline-flex items-center px-4 py-2 bg-purple-100 dark:bg-purple-900/30 rounded-full"
<span class="text-sm font-medium text-purple-700 dark:text-purple-300"> >
<Icon
name="heroicons:lock-closed"
class="w-5 h-5 text-purple-600 dark:text-purple-400 mr-2"
/>
<span
class="text-sm font-medium text-purple-700 dark:text-purple-300"
>
Members Only Event - Open to all circles and contribution levels Members Only Event - Open to all circles and contribution levels
</span> </span>
</div> </div>
</div> </div>
<!-- Target Circles --> <!-- Target Circles -->
<div v-if="event.targetCircles && event.targetCircles.length > 0" class="mb-8"> <div
v-if="event.targetCircles && event.targetCircles.length > 0"
class="mb-8"
>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<Icon name="heroicons:user-group" class="w-5 h-5 text-blue-600 dark:text-blue-400" /> <Icon name="heroicons:user-group" class="w-5 h-5 text-blue-400" />
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Recommended for:</span> <span class="text-sm font-medium text-stone-200"
>Recommended for:</span
>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<span <span
v-for="circle in event.targetCircles" v-for="circle in event.targetCircles"
:key="circle" :key="circle"
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400" class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-blue-900/30 text-blue-400"
> >
{{ formatCircleName(circle) }} {{ formatCircleName(circle) }}
</span> </span>
@ -130,32 +170,64 @@
<!-- Event Description --> <!-- Event Description -->
<div class="prose prose-lg dark:prose-invert max-w-none mb-12"> <div class="prose prose-lg dark:prose-invert max-w-none mb-12">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">About This Event</h2> <h2 class="text-2xl font-bold text-stone-100 mb-4">
<p class="text-gray-700 dark:text-gray-300">{{ event.description }}</p> About This Event
</h2>
<p class="text-stone-200">
{{ event.description }}
</p>
<div v-if="event.agenda && event.agenda.length > 0" class="mt-8"> <div v-if="event.agenda && event.agenda.length > 0" class="mt-8">
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Event Agenda</h3> <h3 class="text-xl font-semibold text-stone-100 mb-4">
Event Agenda
</h3>
<ul class="space-y-3"> <ul class="space-y-3">
<li v-for="(item, index) in event.agenda" :key="index" class="flex items-start"> <li
<span class="inline-block w-6 h-6 bg-blue-500 text-white text-sm rounded-full flex items-center justify-center mr-3 mt-0.5"> v-for="(item, index) in event.agenda"
:key="index"
class="flex items-start"
>
<span
class="inline-block w-6 h-6 bg-blue-500 text-white text-sm rounded-full flex items-center justify-center mr-3 mt-0.5"
>
{{ index + 1 }} {{ index + 1 }}
</span> </span>
<span class="text-gray-700 dark:text-gray-300">{{ item }}</span> <span class="text-stone-200">{{ item }}</span>
</li> </li>
</ul> </ul>
</div> </div>
<div v-if="event.speakers && event.speakers.length > 0" class="mt-8"> <div
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Speakers</h3> v-if="event.speakers && event.speakers.length > 0"
class="mt-8"
>
<h3 class="text-xl font-semibold text-stone-100 mb-4">
Speakers
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div v-for="speaker in event.speakers" :key="speaker.name" class="flex items-start space-x-4"> <div
<div class="w-16 h-16 bg-gray-200 dark:bg-gray-700 rounded-full flex items-center justify-center"> v-for="speaker in event.speakers"
<Icon name="heroicons:user" class="w-8 h-8 text-gray-400 dark:text-gray-500" /> :key="speaker.name"
class="flex items-start space-x-4"
>
<div
class="w-16 h-16 bg-stone-700 rounded-full flex items-center justify-center"
>
<Icon
name="heroicons:user"
class="w-8 h-8 text-stone-500"
/>
</div> </div>
<div> <div>
<p class="font-semibold text-gray-900 dark:text-white">{{ speaker.name }}</p> <p class="font-semibold text-stone-100">
<p class="text-sm text-gray-600 dark:text-gray-400">{{ speaker.role }}</p> {{ speaker.name }}
<p class="text-sm text-gray-500 dark:text-gray-500 mt-1">{{ speaker.bio }}</p> </p>
<p class="text-sm text-stone-300">
{{ speaker.role }}
</p>
<p class="text-sm text-stone-400 mt-1">
{{ speaker.bio }}
</p>
</div> </div>
</div> </div>
</div> </div>
@ -163,30 +235,75 @@
</div> </div>
<!-- Registration Section --> <!-- Registration Section -->
<div v-if="!event.isCancelled" class="bg-gray-50 dark:bg-gray-800 rounded-xl p-8 border border-gray-200 dark:border-gray-700"> <div
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-6">Register for This Event</h3> v-if="!event.isCancelled"
class="bg-stone-800 rounded-xl p-8 border border-stone-700"
>
<h3 class="text-xl font-bold text-stone-100 mb-6">
Register for This Event
</h3>
<!-- Registration Status --> <!-- Registration Status -->
<div v-if="registrationStatus === 'registered'" class="mb-6"> <div v-if="registrationStatus === 'registered'" class="mb-6">
<div class="flex items-center p-4 bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800"> <div
<Icon name="heroicons:check-circle" class="w-6 h-6 text-green-600 dark:text-green-400 mr-3" /> class="p-4 bg-green-900/20 rounded-lg border border-green-800"
<div> >
<p class="font-semibold text-green-700 dark:text-green-300">You're registered!</p> <div class="flex items-start justify-between">
<p class="text-sm text-green-600 dark:text-green-400">We've sent a confirmation to your email</p> <div class="flex items-center">
<Icon
name="heroicons:check-circle"
class="w-6 h-6 text-green-400 mr-3"
/>
<div>
<p class="font-semibold text-green-300">
You're registered!
</p>
<p class="text-sm text-green-400">
We've sent a confirmation to your email
</p>
</div>
</div>
<UButton
color="red"
variant="ghost"
size="sm"
@click="handleCancelRegistration"
:loading="isCancelling"
>
Cancel Registration
</UButton>
</div> </div>
</div> </div>
</div> </div>
<!-- Member Gate Warning --> <!-- Member Gate Warning -->
<div v-if="event.membersOnly && !isMember && registrationStatus !== 'registered'" class="mb-6"> <div
<div class="flex items-start p-4 bg-amber-50 dark:bg-amber-900/20 rounded-lg border border-amber-200 dark:border-amber-800"> v-if="
<Icon name="heroicons:exclamation-triangle" class="w-6 h-6 text-amber-600 dark:text-amber-400 mr-3 mt-0.5" /> event.membersOnly &&
!isMember &&
registrationStatus !== 'registered'
"
class="mb-6"
>
<div
class="flex items-start p-4 bg-amber-900/20 rounded-lg border border-amber-800"
>
<Icon
name="heroicons:exclamation-triangle"
class="w-6 h-6 text-amber-400 mr-3 mt-0.5"
/>
<div> <div>
<p class="font-semibold text-amber-700 dark:text-amber-300">Membership Required</p> <p class="font-semibold text-amber-300">
<p class="text-sm text-amber-600 dark:text-amber-400 mt-1"> Membership Required
This event is exclusive to Ghost Guild members. Join any circle to gain access.
</p> </p>
<NuxtLink to="/join" class="inline-flex items-center text-sm font-medium text-amber-700 dark:text-amber-300 hover:underline mt-2"> <p class="text-sm text-amber-400 mt-1">
This event is exclusive to Ghost Guild members. Join any
circle to gain access.
</p>
<NuxtLink
to="/join"
class="inline-flex items-center text-sm font-medium text-amber-300 hover:underline mt-2"
>
Become a member Become a member
<Icon name="heroicons:arrow-right" class="w-4 h-4 ml-1" /> <Icon name="heroicons:arrow-right" class="w-4 h-4 ml-1" />
</NuxtLink> </NuxtLink>
@ -195,72 +312,94 @@
</div> </div>
<!-- Registration Form --> <!-- Registration Form -->
<form v-if="registrationStatus !== 'registered'" @submit.prevent="handleRegistration" class="space-y-4"> <form
<div> v-if="registrationStatus !== 'registered'"
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> @submit.prevent="handleRegistration"
Full Name class="space-y-4"
</label> >
<UInput <!-- Show form fields only for public events OR for logged-in members -->
id="name" <template v-if="!event.membersOnly || isMember">
v-model="registrationForm.name" <div>
type="text" <label
required for="name"
:disabled="event.membersOnly && !isMember" class="block text-sm font-medium text-stone-200 mb-2"
placeholder="Enter your full name" >
/> Full Name
</div> </label>
<UInput
id="name"
v-model="registrationForm.name"
type="text"
required
placeholder="Enter your full name"
/>
</div>
<div> <div>
<label for="email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label
Email Address for="email"
</label> class="block text-sm font-medium text-stone-200 mb-2"
<UInput >
id="email" Email Address
v-model="registrationForm.email" </label>
type="email" <UInput
required id="email"
:disabled="event.membersOnly && !isMember" v-model="registrationForm.email"
placeholder="Enter your email" type="email"
/> required
</div> placeholder="Enter your email"
/>
<div> </div>
<label for="membershipLevel" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Membership Status
</label>
<USelect
id="membershipLevel"
v-model="registrationForm.membershipLevel"
:options="membershipOptions"
:disabled="event.membersOnly && !isMember"
/>
</div>
<div>
<label
for="membershipLevel"
class="block text-sm font-medium text-stone-200 mb-2"
>
Membership Status
</label>
<USelect
id="membershipLevel"
v-model="registrationForm.membershipLevel"
:options="membershipOptions"
/>
</div>
</template>
<div class="pt-4"> <div class="pt-4">
<UButton <UButton
type="submit" v-if="!event.membersOnly || isMember"
color="primary" type="submit"
size="lg" color="primary"
size="lg"
block block
:disabled="event.membersOnly && !isMember"
:loading="isRegistering" :loading="isRegistering"
> >
{{ isRegistering ? 'Registering...' : 'Register for Event' }} {{ isRegistering ? "Registering..." : "Register for Event" }}
</UButton> </UButton>
<NuxtLink v-else to="/join" class="block">
<UButton color="primary" size="lg" block>
Become a Member to Register
</UButton>
</NuxtLink>
</div> </div>
</form> </form>
<!-- Event Capacity --> <!-- Event Capacity -->
<div v-if="event.maxAttendees" class="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700"> <div
v-if="event.maxAttendees"
class="mt-6 pt-6 border-t border-stone-700"
>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400">Event Capacity</span> <span class="text-sm text-stone-300">Event Capacity</span>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<span class="text-sm font-semibold text-gray-900 dark:text-white"> <span class="text-sm font-semibold text-stone-100">
{{ event.registeredCount || 0 }} / {{ event.maxAttendees }} {{ event.registeredCount || 0 }} / {{ event.maxAttendees }}
</span> </span>
<div class="w-24 h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden"> <div
<div class="w-24 h-2 bg-stone-700 rounded-full overflow-hidden"
>
<div
class="h-full bg-blue-500 rounded-full" class="h-full bg-blue-500 rounded-full"
:style="`width: ${((event.registeredCount || 0) / event.maxAttendees) * 100}%`" :style="`width: ${((event.registeredCount || 0) / event.maxAttendees) * 100}%`"
/> />
@ -271,12 +410,15 @@
</div> </div>
<!-- Additional Information --> <!-- Additional Information -->
<div class="mt-8 p-6 bg-blue-50 dark:bg-blue-900/20 rounded-xl border border-blue-200 dark:border-blue-800"> <div class="mt-8 p-6 bg-stone-800 rounded-xl border border-stone-700">
<h4 class="font-semibold text-gray-900 dark:text-white mb-3">Questions?</h4> <h4 class="font-semibold text-stone-100 mb-3">Questions?</h4>
<p class="text-sm text-gray-700 dark:text-gray-300 mb-3"> <p class="text-sm text-stone-200 mb-3">
If you have any questions about this event, please reach out to our events team. If you have any questions about this event please drop us a line.
</p> </p>
<a href="mailto:events@ghostguild.org" class="inline-flex items-center text-blue-600 dark:text-blue-400 hover:underline"> <a
href="mailto:events@ghostguild.org"
class="inline-flex items-center text-blue-400 hover:underline"
>
<Icon name="heroicons:envelope" class="w-4 h-4 mr-2" /> <Icon name="heroicons:envelope" class="w-4 h-4 mr-2" />
events@ghostguild.org events@ghostguild.org
</a> </a>
@ -288,152 +430,254 @@
</template> </template>
<script setup> <script setup>
const route = useRoute() const route = useRoute();
const toast = useToast() const toast = useToast();
// Fetch event data from API // Fetch event data from API
const { data: event, pending, error } = await useFetch(`/api/events/${route.params.id}`) const {
data: event,
pending,
error,
} = await useFetch(`/api/events/${route.params.id}`);
// Handle event not found // Handle event not found
if (error.value?.statusCode === 404) { if (error.value?.statusCode === 404) {
throw createError({ throw createError({
statusCode: 404, statusCode: 404,
statusMessage: 'Event not found' statusMessage: "Event not found",
}) });
} }
// Check if user is a member (this would normally come from auth/store) // Authentication
const isMember = ref(false) // Set to true if user is logged in and is a member const { isMember, memberData, checkMemberStatus } = useAuth();
// Check member status on mount
onMounted(async () => {
await checkMemberStatus();
// Pre-fill form if member is logged in
if (memberData.value) {
registrationForm.value.name = memberData.value.name;
registrationForm.value.email = memberData.value.email;
registrationForm.value.membershipLevel =
memberData.value.membershipLevel || "non-member";
// Check if user is already registered
await checkRegistrationStatus();
}
});
// Check if user is already registered for this event
const checkRegistrationStatus = async () => {
if (!memberData.value?.email) return;
try {
const response = await $fetch(
`/api/events/${route.params.id}/check-registration`,
{
method: "POST",
body: { email: memberData.value.email },
},
);
if (response.isRegistered) {
registrationStatus.value = "registered";
}
} catch (error) {
console.error("Failed to check registration status:", error);
}
};
// Registration form state // Registration form state
const registrationForm = ref({ const registrationForm = ref({
name: '', name: "",
email: '', email: "",
membershipLevel: 'non-member' membershipLevel: "non-member",
}) });
const membershipOptions = [ const membershipOptions = [
{ label: 'Non-member', value: 'non-member' }, { label: "Non-member", value: "non-member" },
{ label: 'Circle of Community', value: 'community' }, { label: "Circle of Community", value: "community" },
{ label: 'Circle of Founders', value: 'founder' }, { label: "Circle of Founders", value: "founder" },
{ label: 'Circle of Practitioners', value: 'practitioner' } { label: "Circle of Practitioners", value: "practitioner" },
] ];
const isRegistering = ref(false) const isRegistering = ref(false);
const registrationStatus = ref('not-registered') // 'not-registered', 'registered' const isCancelling = ref(false);
const registrationStatus = ref("not-registered"); // 'not-registered', 'registered'
// Format date for display // Format date for display
const formatDate = (dateString) => { const formatDate = (dateString) => {
const date = new Date(dateString) const date = new Date(dateString);
return new Intl.DateTimeFormat('en-US', { return new Intl.DateTimeFormat("en-US", {
weekday: 'long', weekday: "long",
year: 'numeric', year: "numeric",
month: 'long', month: "long",
day: 'numeric' day: "numeric",
}).format(date) }).format(date);
} };
// Format time range for display // Format time range for display
const formatTime = (startDate, endDate) => { const formatTime = (startDate, endDate) => {
const start = new Date(startDate) const start = new Date(startDate);
const end = new Date(endDate) const end = new Date(endDate);
const timeFormat = new Intl.DateTimeFormat('en-US', { const timeFormat = new Intl.DateTimeFormat("en-US", {
hour: 'numeric', hour: "numeric",
minute: '2-digit', minute: "2-digit",
timeZoneName: 'short' timeZoneName: "short",
}) });
return `${timeFormat.format(start)} - ${timeFormat.format(end)}` return `${timeFormat.format(start)} - ${timeFormat.format(end)}`;
} };
// Format circle name for display // Format circle name for display
const formatCircleName = (circleValue) => { const formatCircleName = (circleValue) => {
const circleNames = { const circleNames = {
'community': 'Community Circle', community: "Community Circle",
'founder': 'Founder Circle', founder: "Founder Circle",
'practitioner': 'Practitioner Circle' practitioner: "Practitioner Circle",
} };
return circleNames[circleValue] || circleValue return circleNames[circleValue] || circleValue;
} };
// Get optimized Cloudinary image URL // Get optimized Cloudinary image URL
const getOptimizedImageUrl = (publicId, transformations) => { const getOptimizedImageUrl = (publicId, transformations) => {
if (!publicId) return '' if (!publicId) return "";
const config = useRuntimeConfig() const config = useRuntimeConfig();
return `https://res.cloudinary.com/${config.public.cloudinaryCloudName}/image/upload/${transformations}/f_auto,q_auto/${publicId}` return `https://res.cloudinary.com/${config.public.cloudinaryCloudName}/image/upload/${transformations}/f_auto,q_auto/${publicId}`;
} };
// Get image URL with fallback logic // Get image URL with fallback logic
const getImageUrl = (featureImage) => { const getImageUrl = (featureImage) => {
if (!featureImage) return '' if (!featureImage) return "";
// If we have a direct URL, use it as primary (since seed data uses external URLs) // If we have a direct URL, use it as primary (since seed data uses external URLs)
if (featureImage.url) { if (featureImage.url) {
return featureImage.url return featureImage.url;
} }
// Fallback to Cloudinary if we have a publicId // Fallback to Cloudinary if we have a publicId
if (featureImage.publicId) { if (featureImage.publicId) {
return getOptimizedImageUrl(featureImage.publicId, 'w_1200,h_400,c_fill') return getOptimizedImageUrl(featureImage.publicId, "w_1200,h_400,c_fill");
} }
return '' return "";
} };
// Handle image loading errors // Handle image loading errors
const handleImageError = (event) => { const handleImageError = (event) => {
console.warn('Image failed to load:', event.target.src) console.warn("Image failed to load:", event.target.src);
// Optionally hide the image container or show a placeholder // Optionally hide the image container or show a placeholder
} };
// Handle registration submission // Handle registration submission
const handleRegistration = async () => { const handleRegistration = async () => {
isRegistering.value = true isRegistering.value = true;
try { try {
// Submit registration to API using slug or ID // Submit registration to API using slug or ID
const response = await $fetch(`/api/events/${route.params.id}/register`, { const response = await $fetch(`/api/events/${route.params.id}/register`, {
method: 'POST', method: "POST",
body: registrationForm.value body: registrationForm.value,
}) });
// Update registration status // Update registration status
registrationStatus.value = 'registered' registrationStatus.value = "registered";
// Show success toast // Show success toast
toast.add({ toast.add({
title: 'Registration Successful!', title: "Registration Successful!",
description: `You're registered for ${event.value.title}. Check your email for confirmation.`, description: `You're registered for ${event.value.title}. Check your email for confirmation.`,
color: 'green' color: "green",
}) });
// Update registered count // Update registered count
if (event.value.registeredCount !== undefined) { if (event.value.registeredCount !== undefined) {
event.value.registeredCount++ event.value.registeredCount++;
} }
} catch (error) { } catch (error) {
console.error('Registration failed:', error) console.error("Registration failed:", error);
// Handle specific error messages // Handle specific error messages
const errorMessage = error.data?.statusMessage || 'Something went wrong. Please try again.' const errorMessage =
error.data?.statusMessage || "Something went wrong. Please try again.";
toast.add({ toast.add({
title: 'Registration Failed', title: "Registration Failed",
description: errorMessage, description: errorMessage,
color: 'red' color: "red",
}) });
} finally { } finally {
isRegistering.value = false isRegistering.value = false;
} }
} };
// Handle registration cancellation
const handleCancelRegistration = async () => {
if (
!confirm(
"Are you sure you want to cancel your registration for this event?",
)
) {
return;
}
isCancelling.value = true;
try {
const response = await $fetch(
`/api/events/${route.params.id}/cancel-registration`,
{
method: "POST",
body: {
email: registrationForm.value.email || memberData.value?.email,
},
},
);
// Update registration status
registrationStatus.value = "not-registered";
// Show success toast
toast.add({
title: "Registration Cancelled",
description: "Your registration has been cancelled successfully.",
color: "blue",
});
// Update registered count
if (event.value.registeredCount !== undefined) {
event.value.registeredCount--;
}
} catch (error) {
console.error("Cancel registration failed:", error);
const errorMessage =
error.data?.statusMessage ||
"Failed to cancel registration. Please try again.";
toast.add({
title: "Cancellation Failed",
description: errorMessage,
color: "red",
});
} finally {
isCancelling.value = false;
}
};
// SEO Meta // SEO Meta
useHead(() => ({ useHead(() => ({
title: event.value ? `${event.value.title} - Ghost Guild Events` : 'Event - Ghost Guild', title: event.value
? `${event.value.title} - Ghost Guild Events`
: "Event - Ghost Guild",
meta: [ meta: [
{ name: 'description', content: event.value?.description || 'View event details and register' } {
] name: "description",
})) content: event.value?.description || "View event details and register",
</script> },
],
}));
</script>

View file

@ -1,289 +1,322 @@
<template> <template>
<div> <div>
<!-- Page Header --> <!-- Page Header -->
<PageHeader <PageHeader
title="Events" title="Events"
subtitle="Join our community events, workshops, and gatherings designed to connect developers and share knowledge about cooperative game development." subtitle="Join our community events, workshops, and gatherings"
theme="blue"
size="large" size="large"
/> />
<!-- Event Calendar --> <!-- Events Section with Tabs -->
<section class="py-20 bg-white dark:bg-gray-900"> <section class="py-20 bg-stone-900 dark:bg-stone-950">
<UContainer> <UContainer>
<div class="text-center mb-12"> <UTabs
<h2 class="text-3xl font-bold text-blue-600 dark:text-blue-400 mb-8"> v-model="activeTab"
Event Calendar :items="[
</h2> { label: 'Upcoming Events', value: 'upcoming', slot: 'upcoming' },
<div class="flex items-center justify-center gap-2 mb-8"> { label: 'Calendar', value: 'calendar', slot: 'calendar' },
<div class="w-6 h-6 bg-blue-500 rounded-full" /> ]"
<div class="w-6 h-6 bg-blue-400 rounded-full" /> class="max-w-6xl mx-auto"
<div class="w-8 h-1 bg-blue-300 rounded-full" /> >
<div class="w-8 h-1 bg-blue-200 rounded-full" /> <template #upcoming>
<div class="w-8 h-1 bg-blue-100 rounded-full" /> <div class="max-w-4xl mx-auto space-y-6 pt-8">
</div> <NuxtLink
</div> v-for="event in upcomingEvents"
:key="event.id"
<div class="max-w-5xl mx-auto"> :to="`/events/${event.slug || event.id}`"
<div class="bg-gray-50 dark:bg-gray-800 rounded-2xl p-6 border border-gray-200 dark:border-gray-700"> class="group flex items-start gap-4 py-2 hover:opacity-80 transition-opacity"
<ClientOnly> >
<div v-if="pending" class="min-h-[400px] bg-gray-100 dark:bg-gray-700 rounded-xl flex items-center justify-center"> <div class="flex-shrink-0 text-center">
<div class="text-center"> <div class="text-2xl font-bold text-stone-100">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-4"></div> {{ event.start.getDate() }}
<p class="text-gray-600 dark:text-gray-400">Loading events...</p> </div>
</div> <div class="text-xs text-stone-400 uppercase">
</div> {{
<VueCal event.start.toLocaleDateString("en-US", {
v-else month: "short",
:events="events" })
:time="false" }}
active-view="month"
class="custom-calendar"
:disable-views="['years', 'year']"
:hide-weekends="false"
today-button
events-on-month-view="short"
:editable-events="{
title: false,
drag: false,
resize: false,
delete: false,
create: false
}"
@event-click="onEventClick"
/>
<template #fallback>
<div class="min-h-[400px] bg-gray-100 dark:bg-gray-700 rounded-xl flex items-center justify-center">
<div class="text-center">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-4"></div>
<p class="text-gray-600 dark:text-gray-400">Loading calendar...</p>
</div> </div>
</div> </div>
</template>
</ClientOnly> <div class="flex-1 min-w-0">
</div> <div class="flex items-start gap-2 mb-1">
</div> <h3
class="text-lg font-semibold text-stone-100 group-hover:text-blue-400 transition-colors"
>
{{ event.title }}
</h3>
<Icon
v-if="event.membersOnly"
name="heroicons:lock-closed"
class="w-4 h-4 text-purple-500 flex-shrink-0 mt-1"
/>
</div>
<p class="text-sm text-stone-300 mb-2 line-clamp-2">
{{ event.content }}
</p>
<div
v-if="event.series?.isSeriesEvent"
class="flex items-center gap-1 text-xs text-purple-600 dark:text-purple-400"
>
<div
class="w-4 h-4 bg-purple-100 dark:bg-purple-900/30 rounded-full flex items-center justify-center text-xs font-bold"
>
{{ event.series.position }}
</div>
{{ event.series.title }}
</div>
</div>
<Icon
name="heroicons:arrow-right"
class="w-5 h-5 text-stone-400 group-hover:text-blue-400 group-hover:translate-x-1 transition-all flex-shrink-0 mt-1"
/>
</NuxtLink>
</div>
</template>
<template #calendar>
<div class="pt-8">
<ClientOnly>
<div
v-if="pending"
class="min-h-[400px] bg-stone-700 rounded-xl flex items-center justify-center"
>
<div class="text-center">
<p class="text-stone-200">Loading events...</p>
</div>
</div>
<VueCal
v-else
:events="events"
:time="false"
active-view="month"
class="custom-calendar"
:disable-views="['years', 'year']"
:hide-weekends="false"
today-button
events-on-month-view="short"
:editable-events="{
title: false,
drag: false,
resize: false,
delete: false,
create: false,
}"
@event-click="onEventClick"
/>
<template #fallback>
<div
class="min-h-[400px] bg-stone-700 rounded-xl flex items-center justify-center"
>
<div class="text-center">
<div
class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-4"
></div>
<p class="text-stone-200">Loading calendar...</p>
</div>
</div>
</template>
</ClientOnly>
</div>
</template>
</UTabs>
</UContainer> </UContainer>
</section> </section>
<!-- Upcoming Events --> <!-- Event Series -->
<section class="py-20 bg-gray-50 dark:bg-gray-800"> <section
v-if="activeSeries.length > 0"
class="py-20 bg-stone-800 dark:bg-stone-900"
>
<UContainer> <UContainer>
<div class="text-center mb-12"> <div class="text-center mb-12">
<h2 class="text-3xl font-bold text-blue-600 dark:text-blue-400 mb-8"> <h2 class="text-3xl font-bold text-stone-100 mb-8">
Upcoming Events Active Event Series
</h2> </h2>
<p class="text-stone-300 max-w-2xl mx-auto">
Multi-part workshops and recurring events designed to deepen your
knowledge and build community connections.
</p>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-6xl mx-auto"> <div
<NuxtLink class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-6xl mx-auto"
v-for="event in upcomingEvents" >
:key="event.id" <div
:to="`/events/${event.slug || event.id}`" v-for="series in activeSeries.slice(0, 6)"
class="group bg-white dark:bg-gray-900 rounded-xl overflow-hidden shadow-lg border border-gray-200 dark:border-gray-700 hover:border-blue-400 dark:hover:border-blue-600 transition-all hover:shadow-xl" :key="series.id"
class="bg-stone-900 rounded-xl p-6 shadow-lg border border-stone-700"
> >
<!-- Feature Image --> <div class="flex items-start justify-between mb-4">
<div v-if="event.featureImage?.url" class="aspect-video w-full overflow-hidden"> <div
<img :class="[
:src="getImageUrl(event.featureImage)"
:alt="event.featureImage.alt || event.title"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
@error="handleImageError"
/>
</div>
<div class="p-6">
<div class="flex items-start justify-between mb-4">
<div :class="[
'inline-flex items-center px-3 py-1 rounded-full text-xs font-medium', 'inline-flex items-center px-3 py-1 rounded-full text-xs font-medium',
event.class === 'event-community' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' : getSeriesTypeBadgeClass(series.type),
event.class === 'event-workshop' ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' : ]"
event.class === 'event-social' ? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' : >
'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400' {{ formatSeriesType(series.type) }}
]"> </div>
{{ event.class === 'event-community' ? 'Community' : <div class="flex items-center gap-1 text-xs text-stone-400">
event.class === 'event-workshop' ? 'Workshop' : <Icon name="heroicons:calendar-days" class="w-4 h-4" />
event.class === 'event-social' ? 'Social' : 'Showcase' }} <span>{{ series.eventCount }} events</span>
</div>
</div>
<h3 class="text-lg font-semibold text-stone-100 mb-2">
{{ series.title }}
</h3>
<p class="text-sm text-stone-300 mb-4 line-clamp-2">
{{ series.description }}
</p>
<div class="space-y-2 mb-4">
<div
v-for="event in series.events.slice(0, 3)"
:key="event.id"
class="flex items-center justify-between text-xs"
>
<div class="flex items-center gap-2">
<div
class="w-6 h-6 bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400 rounded-full flex items-center justify-center text-xs font-medium"
>
{{ event.series?.position || "?" }}
</div>
<span class="text-stone-300 truncate">{{ event.title }}</span>
</div> </div>
<Icon v-if="event.membersOnly" name="heroicons:lock-closed" class="w-4 h-4 text-purple-500" /> <span class="text-stone-400">
</div> {{ formatEventDate(event.startDate) }}
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">
{{ event.title }}
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
{{ event.content }}
</p>
<div class="flex items-center text-sm text-gray-500 dark:text-gray-500">
<Icon name="heroicons:calendar" class="w-4 h-4 mr-1" />
{{ formatEventDate(event.start) }}
</div>
<div class="mt-4 pt-4 border-t border-gray-100 dark:border-gray-800">
<span class="inline-flex items-center text-sm font-medium text-blue-600 dark:text-blue-400 group-hover:translate-x-1 transition-transform">
View Details
<Icon name="heroicons:arrow-right" class="w-4 h-4 ml-1" />
</span> </span>
</div> </div>
<div
v-if="series.events.length > 3"
class="text-xs text-stone-400 text-center pt-1"
>
+{{ series.events.length - 3 }} more events
</div>
</div> </div>
</NuxtLink>
<div class="flex items-center justify-between text-sm">
<div class="text-stone-400">
{{ formatDateRange(series.startDate, series.endDate) }}
</div>
<span
:class="[
'inline-flex items-center px-2 py-1 rounded-full text-xs font-medium',
series.status === 'active'
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: series.status === 'upcoming'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
: 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400',
]"
>
{{ series.status }}
</span>
</div>
</div>
</div> </div>
</UContainer> </UContainer>
</section> </section>
<!-- Attend Our Events --> <!-- Attend Our Events -->
<section class="py-20 bg-white dark:bg-gray-900"> <section class="py-20 bg-stone-800 dark:bg-stone-900">
<UContainer> <UContainer>
<div class="text-center mb-16"> <div class="text-center mb-16">
<h2 class="text-3xl font-bold text-blue-600 dark:text-blue-400 mb-8"> <h2 class="text-3xl font-bold text-stone-100 mb-8">
Attend Our Events Attend Our Events
</h2> </h2>
</div> </div>
<div class="max-w-4xl mx-auto"> <div class="max-w-4xl mx-auto">
<div class="bg-blue-50 dark:bg-blue-900/20 rounded-2xl p-8 border border-blue-200 dark:border-blue-800 mb-12"> <div
<div class="space-y-6 mb-8"> class="bg-stone-900 rounded-2xl p-8 border border-stone-700 mb-12"
<div class="h-2 bg-blue-500 rounded-full" /> >
<div class="h-2 bg-blue-400 rounded-full w-5/6" />
<div class="h-2 bg-blue-300 rounded-full w-2/3" />
</div>
<div class="prose prose-lg dark:prose-invert max-w-none"> <div class="prose prose-lg dark:prose-invert max-w-none">
<p class="text-lg leading-relaxed text-gray-700 dark:text-gray-300 mb-6"> <p class="text-lg leading-relaxed text-stone-200 mb-6">
Our events are designed to build community, share knowledge, and support developers exploring cooperative models. From informal networking sessions to structured workshops, there's something for everyone. Our events are ,Lorem ipsum, dolor sit amet consectetur
adipisicing elit. Quibusdam exercitationem delectus ab
voluptates aspernatur, quia deleniti aut maxime, veniam
accusantium non dolores saepe error, ipsam laudantium asperiores
dolorum alias nulla!
</p> </p>
<p class="text-lg leading-relaxed text-gray-700 dark:text-gray-300 mb-6"> <p class="text-lg leading-relaxed text-stone-200 mb-6">
Regular events include monthly community meetups, quarterly workshops on cooperative business structures, and seasonal social gatherings. We also host special events featuring guest speakers and collaborative project showcases. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do
</p> eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
enim ad minim veniam, quis nostrud exercitation ullamco laboris
<p class="text-lg leading-relaxed text-gray-700 dark:text-gray-300"> nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor
All events are welcoming to developers at any stage of their cooperative journey, from those just curious about alternative models to experienced co-op members sharing their insights. in reprehenderit in voluptate velit esse cillum dolore eu fugiat
nulla pariatur. Excepteur sint occaecat cupidatat non proident,
sunt in culpa qui officia deserunt mollit anim id est laborum.
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do
eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
enim ad minim veniam, quis nostrud exercitation ullamco laboris
nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor
in reprehenderit in voluptate velit esse cillum dolore eu fugiat
nulla pariatur. Excepteur sint occaecat cupidatat non proident,
sunt in culpa qui officia deserunt mollit anim id est laborum.
</p> </p>
</div> </div>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8"> <div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<div class="text-center"> <div class="text-center">
<div class="w-16 h-16 bg-blue-100 dark:bg-blue-900/30 rounded-2xl flex items-center justify-center mx-auto mb-4"> <h3 class="text-lg font-semibold text-stone-100 mb-2">
<div class="w-8 h-8 bg-blue-500 rounded" /> Monthly Meetups
</div> </h3>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">Monthly Meetups</h3>
<div class="space-y-1 mb-3"> <p class="text-sm text-stone-300">
<div class="h-1 bg-blue-500 rounded-full" /> Casual knowledge sharing sessions
<div class="h-1 bg-blue-300 rounded-full w-3/4 mx-auto" />
</div>
<p class="text-sm text-gray-600 dark:text-gray-400">
Casual networking and knowledge sharing sessions
</p> </p>
</div> </div>
<div class="text-center"> <div class="text-center">
<div class="w-16 h-16 bg-emerald-100 dark:bg-emerald-900/30 rounded-2xl flex items-center justify-center mx-auto mb-4"> <h3 class="text-lg font-semibold text-stone-100 mb-2">
<div class="w-8 h-8 bg-emerald-500" style="clip-path: polygon(50% 0%, 0% 100%, 100% 100%)" /> Workshops
</div> </h3>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">Workshops</h3>
<div class="space-y-1 mb-3"> <p class="text-sm text-stone-300">
<div class="h-1 bg-emerald-500 rounded-full" /> Hands-on learning about cooperative and worker-centric business
<div class="h-1 bg-emerald-300 rounded-full w-5/6 mx-auto" /> models
</div>
<p class="text-sm text-gray-600 dark:text-gray-400">
Hands-on learning about cooperative business models
</p> </p>
</div> </div>
<div class="text-center"> <div class="text-center">
<div class="w-16 h-16 bg-purple-100 dark:bg-purple-900/30 rounded-2xl flex items-center justify-center mx-auto mb-4"> <h3 class="text-lg font-semibold text-stone-100 mb-2">
<div class="w-8 h-8 bg-purple-500 rounded-full" /> Social Events
</div> </h3>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">Social Events</h3> <p class="text-sm text-stone-300">
<div class="space-y-1 mb-3"> Game nights, socials, and more
<div class="h-1 bg-purple-500 rounded-full" />
<div class="h-1 bg-purple-300 rounded-full w-2/3 mx-auto" />
</div>
<p class="text-sm text-gray-600 dark:text-gray-400">
Community building and celebration gatherings
</p> </p>
</div> </div>
</div> </div>
</div> </div>
</UContainer> </UContainer>
</section> </section>
<!-- Event Highlights -->
<section class="py-20 bg-gray-50 dark:bg-gray-800">
<UContainer>
<div class="text-center mb-16">
<h2 class="text-3xl font-bold text-blue-600 dark:text-blue-400 mb-8">
Event Highlights
</h2>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-12 max-w-6xl mx-auto">
<div class="space-y-6">
<div class="space-y-4">
<div class="h-2 bg-blue-500 rounded-full" />
<div class="h-2 bg-blue-400 rounded-full w-5/6" />
<div class="h-2 bg-blue-300 rounded-full w-3/4" />
<div class="h-2 bg-blue-200 rounded-full w-1/2" />
</div>
<div class="space-y-6">
<div>
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-3">
Recent Highlights
</h3>
<p class="text-gray-600 dark:text-gray-400 leading-relaxed mb-4">
Our latest workshop on "Building Sustainable Game Co-ops" brought together 50+ developers to explore practical strategies for transitioning to cooperative models.
</p>
<p class="text-gray-600 dark:text-gray-400 leading-relaxed">
The quarterly showcase featured three member studios presenting their games and sharing insights about democratic decision-making in creative projects.
</p>
</div>
<div>
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-3">
Upcoming Features
</h3>
<p class="text-gray-600 dark:text-gray-400 leading-relaxed">
Next month's event will include a panel discussion on funding cooperative studios, featuring successful co-op founders and supporting investors.
</p>
</div>
</div>
</div>
<div class="flex items-center justify-center">
<div class="w-full max-w-md h-64 bg-blue-100 dark:bg-blue-900/30 rounded-2xl border-2 border-dashed border-blue-300 dark:border-blue-700 flex items-center justify-center">
<div class="text-center">
<div class="w-16 h-16 bg-blue-200 dark:bg-blue-800 rounded-xl flex items-center justify-center mx-auto mb-4">
<div class="w-8 h-8 bg-blue-500 rounded" />
</div>
<p class="text-blue-600 dark:text-blue-400 font-medium">Event Photos</p>
<p class="text-sm text-blue-500 dark:text-blue-500 mt-2">Coming Soon</p>
</div>
</div>
</div>
</div>
</UContainer>
</section>
</div> </div>
</template> </template>
<script setup> <script setup>
import { VueCal } from 'vue-cal' import { VueCal } from "vue-cal";
import 'vue-cal/style.css' import "vue-cal/style.css";
// Active tab state
const activeTab = ref("upcoming");
// Fetch events from API // Fetch events from API
const { data: eventsData, pending, error } = await useFetch('/api/events') const { data: eventsData, pending, error } = await useFetch("/api/events");
// Fetch series from API
const { data: seriesData } = await useFetch("/api/series");
// Transform events for calendar display // Transform events for calendar display
const events = computed(() => { const events = computed(() => {
if (!eventsData.value) return [] if (!eventsData.value) return [];
return eventsData.value.map(event => ({ return eventsData.value.map((event) => ({
id: event.id || event._id, id: event.id || event._id,
slug: event.slug, slug: event.slug,
start: new Date(event.startDate), start: new Date(event.startDate),
@ -296,83 +329,262 @@ const events = computed(() => {
location: event.location, location: event.location,
registeredCount: event.registeredCount, registeredCount: event.registeredCount,
maxAttendees: event.maxAttendees, maxAttendees: event.maxAttendees,
featureImage: event.featureImage featureImage: event.featureImage,
})) series: event.series,
}) }));
});
// Get active event series
const activeSeries = computed(() => {
if (!seriesData.value) return [];
return seriesData.value.filter(
(series) =>
series.status === "active" || series.isOngoing || series.isUpcoming,
);
});
// Get upcoming events (future events) // Get upcoming events (future events)
const upcomingEvents = computed(() => { const upcomingEvents = computed(() => {
const now = new Date() const now = new Date();
return events.value return events.value
.filter(event => event.start > now) .filter((event) => event.start > now)
.sort((a, b) => a.start - b.start) .sort((a, b) => a.start - b.start)
.slice(0, 6) // Show max 6 upcoming events .slice(0, 6); // Show max 6 upcoming events
}) });
// Format event date for display // Format event date for display
const formatEventDate = (date) => { const formatEventDate = (date) => {
return new Intl.DateTimeFormat('en-US', { return new Intl.DateTimeFormat("en-US", {
month: 'short', month: "short",
day: 'numeric', day: "numeric",
year: 'numeric' year: "numeric",
}).format(date) }).format(date);
} };
// Get optimized Cloudinary image URL // Get optimized Cloudinary image URL
const getOptimizedImageUrl = (publicId, transformations) => { const getOptimizedImageUrl = (publicId, transformations) => {
if (!publicId) return '' if (!publicId) return "";
const config = useRuntimeConfig() const config = useRuntimeConfig();
return `https://res.cloudinary.com/${config.public.cloudinaryCloudName}/image/upload/${transformations}/f_auto,q_auto/${publicId}` return `https://res.cloudinary.com/${config.public.cloudinaryCloudName}/image/upload/${transformations}/f_auto,q_auto/${publicId}`;
} };
// Get image URL with fallback logic // Get image URL with fallback logic
const getImageUrl = (featureImage) => { const getImageUrl = (featureImage) => {
if (!featureImage) return '' if (!featureImage) return "";
// If we have a direct URL, use it as primary (since seed data uses external URLs) // If we have a direct URL, use it as primary (since seed data uses external URLs)
if (featureImage.url) { if (featureImage.url) {
return featureImage.url return featureImage.url;
} }
// Fallback to Cloudinary if we have a publicId // Fallback to Cloudinary if we have a publicId
if (featureImage.publicId) { if (featureImage.publicId) {
return getOptimizedImageUrl(featureImage.publicId, 'w_400,h_200,c_fill') return getOptimizedImageUrl(featureImage.publicId, "w_400,h_200,c_fill");
} }
return '' return "";
} };
// Handle image loading errors // Handle image loading errors
const handleImageError = (event) => { const handleImageError = (event) => {
console.warn('Image failed to load:', event.target.src) console.warn("Image failed to load:", event.target.src);
// Optionally hide the image container or show a placeholder // Optionally hide the image container or show a placeholder
} };
// Handle calendar event click // Handle calendar event click
const onEventClick = (event) => { const onEventClick = (event) => {
if (event.id) { if (event.id) {
navigateTo(`/events/${event.slug || event.id}`) navigateTo(`/events/${event.slug || event.id}`);
} }
} };
// Series helper functions
const formatSeriesType = (type) => {
const types = {
workshop_series: "Workshop Series",
recurring_meetup: "Recurring Meetup",
multi_day: "Multi-Day Event",
course: "Course",
tournament: "Tournament",
};
return types[type] || type;
};
const getSeriesTypeBadgeClass = (type) => {
const classes = {
workshop_series:
"bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400",
recurring_meetup:
"bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
multi_day:
"bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400",
course:
"bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400",
tournament: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
};
return (
classes[type] ||
"bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400"
);
};
const formatDateRange = (startDate, endDate) => {
if (!startDate || !endDate) return "No dates";
const start = new Date(startDate);
const end = new Date(endDate);
const startMonth = start.toLocaleDateString("en-US", { month: "short" });
const endMonth = end.toLocaleDateString("en-US", { month: "short" });
const startDay = start.getDate();
const endDay = end.getDate();
const year = end.getFullYear();
if (
start.getMonth() === end.getMonth() &&
start.getFullYear() === end.getFullYear()
) {
return `${startMonth} ${startDay}-${endDay}, ${year}`;
} else if (start.getFullYear() === end.getFullYear()) {
return `${startMonth} ${startDay} - ${endMonth} ${endDay}, ${year}`;
} else {
return `${formatEventDate(startDate)} - ${formatEventDate(endDate)}`;
}
};
</script> </script>
<style> <style scoped>
/* Custom calendar styling to match the site theme */ .line-clamp-2 {
.custom-calendar { display: -webkit-box;
--vuecal-primary-color: #3b82f6; -webkit-line-clamp: 2;
--vuecal-text-color: #374151; -webkit-box-orient: vertical;
--vuecal-border-color: #e5e7eb; overflow: hidden;
--vuecal-header-color: #f9fafb;
--vuecal-today-color: #dbeafe;
} }
.dark .custom-calendar { /* Custom calendar styling to match the site theme */
--vuecal-primary-color: #60a5fa; .custom-calendar {
--vuecal-text-color: #d1d5db; --vuecal-primary-color: #fff;
--vuecal-border-color: #4b5563; --vuecal-text-color: #e7e5e4;
--vuecal-header-color: #374151; --vuecal-border-color: #57534e;
--vuecal-today-color: #1e3a8a; --vuecal-header-color: #1c1917;
--vuecal-today-color: #292524;
background-color: #292524;
}
.custom-calendar :deep(.vuecal__bg) {
background-color: #292524;
}
.custom-calendar :deep(.vuecal__header) {
background-color: #1c1917;
border-bottom: 1px solid #57534e;
}
.custom-calendar :deep(.vuecal__title-bar) {
background-color: #1c1917;
}
.custom-calendar :deep(.vuecal__title) {
color: #e7e5e4;
}
.custom-calendar :deep(.vuecal__weekdays-headings) {
background-color: #1c1917;
border-bottom: 1px solid #57534e;
}
.custom-calendar :deep(.vuecal__heading) {
color: #a8a29e;
}
.custom-calendar :deep(.vuecal__cell) {
background-color: #292524;
border-color: #57534e;
color: #e7e5e4;
}
.custom-calendar :deep(.vuecal__cell:hover) {
background-color: #44403c;
}
.custom-calendar :deep(.vuecal__cell-content) {
color: #e7e5e4;
}
.custom-calendar :deep(.vuecal__cell--today) {
background-color: #44403c;
}
.custom-calendar :deep(.vuecal__cell--out-of-scope) {
background-color: #1c1917;
color: #78716c;
}
.custom-calendar :deep(.vuecal__arrow) {
color: #a8a29e;
}
.custom-calendar :deep(.vuecal__arrow:hover) {
background-color: #44403c;
}
.custom-calendar :deep(.vuecal__today-btn) {
background-color: #44403c;
color: white;
border: 1px solid #78716c;
}
.custom-calendar :deep(.vuecal__today-btn:hover) {
background-color: #57534e;
border-color: #a8a29e;
}
.custom-calendar :deep(.vuecal__view-btn),
.custom-calendar :deep(button[class*="view"]) {
background-color: #44403c !important;
color: #ffffff !important;
border: 1px solid #78716c !important;
font-weight: 600 !important;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
}
.custom-calendar :deep(.vuecal__view-btn:hover),
.custom-calendar :deep(button[class*="view"]:hover) {
background-color: #57534e !important;
border-color: #a8a29e !important;
color: #ffffff !important;
}
.custom-calendar :deep(.vuecal__view-btn--active),
.custom-calendar :deep(button[class*="view"][class*="active"]) {
background-color: #0c0a09 !important;
color: #ffffff !important;
border-color: #a8a29e !important;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
}
.custom-calendar :deep(.vuecal__view-btn--active:hover),
.custom-calendar :deep(button[class*="view"][class*="active"]:hover) {
background-color: #1c1917 !important;
border-color: #d6d3d1 !important;
color: #ffffff !important;
}
.custom-calendar :deep(.vuecal__title-bar button) {
color: #ffffff !important;
font-weight: 600 !important;
}
.custom-calendar :deep(.vuecal__title-bar .default-view-btn) {
background-color: #44403c !important;
color: #ffffff !important;
border: 1px solid #78716c !important;
}
.custom-calendar :deep(.vuecal__title-bar .default-view-btn.active) {
background-color: #0c0a09 !important;
border-color: #a8a29e !important;
} }
/* Event type styling */ /* Event type styling */
@ -411,4 +623,4 @@ const onEventClick = (event) => {
color: var(--vuecal-primary-color); color: var(--vuecal-primary-color);
font-weight: 600; font-weight: 600;
} }
</style> </style>

View file

@ -1,104 +1,114 @@
<template> <template>
<div> <div class="relative">
<!-- Hero Section --> <!-- Experimental Hero Section -->
<PageHeader <section class="mb-24">
title="Discover Ghost Guild" <div class="relative">
subtitle="A community for game developers exploring cooperative models" <!-- Large artistic title -->
theme="blue" <h1
size="hero" class="text-6xl md:text-8xl font-bold text-stone-100 ethereal-text leading-tight mb-8"
:show-interactive-area="true" >
interactive-content="Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." Become a Ghostie
/> </h1>
<!-- Join Us Today --> <!-- Floating subtitle -->
<section class="py-20 bg-white dark:bg-gray-900"> <div class="mb-16">
<UContainer> <p class="text-stone-100 text-lg max-w-md">
<div class="text-center max-w-3xl mx-auto"> A community for creatives and game devs<br />
<h2 class="text-4xl font-bold text-blue-600 dark:text-blue-400 mb-12"> exploring cooperative models
Join Us Today </p>
</h2> </div>
<div class="space-y-6"> <!-- Decorative elements -->
<div class="h-2 bg-blue-500 rounded-full mx-auto max-w-sm" /> <div
<div class="h-12 bg-blue-500 rounded-xl mx-auto max-w-xs flex items-center justify-center"> class="absolute top-0 right-0 w-32 h-32 border border-ghost-800 rounded-full opacity-20"
<UButton to="/join" size="lg" color="primary" class="text-white font-semibold"> />
Get Started <div
</UButton> class="absolute top-20 -left-8 w-16 h-px bg-whisper-500 opacity-40"
/>
</div>
</section>
<!-- Join Section - Offset Layout -->
<section class="mb-32 relative">
<div>
<NuxtLink
to="/join"
class="inline-block px-8 py-3 border border-ghost-600 text-stone-200 hover:bg-ghost-800 hover:border-whisper-500 hover:ethereal-text transition-all duration-500"
>
Join Us Today
</NuxtLink>
</div>
<!-- Decorative corner element -->
<div
class="absolute -right-4 top-0 w-20 h-20 border-t border-r border-ghost-800 opacity-30"
/>
</section>
<!-- Circles - Asymmetrical Grid -->
<section class="mb-32">
<div class="space-y-8">
<div
v-for="(circle, index) in circles"
:key="circle.value"
class="flex gap-8 items-start"
>
<!-- Content -->
<div class="flex-1 max-w-lg">
<h3 class="text-xl text-stone-100 mb-3">{{ circle.label }}</h3>
<p class="text-stone-200 text-sm leading-relaxed mb-4">
{{ circle.description }}
</p>
<!-- Features as inline text -->
<div class="text-sm text-stone-400">
<span v-for="(feature, i) in circle.features" :key="feature">
{{ feature
}}<span v-if="i < circle.features.length - 1"> </span>
</span>
</div> </div>
</div> </div>
<!-- Side accent -->
</div> </div>
</UContainer> </div>
</section> </section>
<!-- About Our Circles --> <!-- Why Join? - Diagonal Layout -->
<section class="py-20 bg-gray-50 dark:bg-gray-800"> <section class="mb-32 relative">
<UContainer> <div class="transform -rotate-1">
<div class="text-center mb-16"> <h2 class="text-3xl font-light text-stone-200 mb-12">Why Join?</h2>
<h2 class="text-4xl font-bold text-blue-600 dark:text-blue-400 mb-8"> </div>
About Our Circles
</h2>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<UCard v-for="circle in circles" :key="circle.value" class="h-full">
<template #header>
<div class="flex items-center gap-4 mb-4">
<div v-if="circle.value === 'community'" class="w-8 h-8 bg-blue-500" style="clip-path: polygon(50% 0%, 0% 100%, 100% 100%)" />
<div v-else-if="circle.value === 'founder'" class="w-8 h-8 bg-blue-500 flex items-center justify-center">
<div class="w-4 h-1 bg-white" />
<div class="w-1 h-4 bg-white absolute" />
</div>
<div v-else class="w-8 h-8 bg-blue-500 rounded" />
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">{{ circle.label }}</h3>
</div>
</template>
<div class="space-y-4">
<p class="text-gray-600 dark:text-gray-300">{{ circle.description }}</p>
<div class="space-y-2">
<div class="h-1 bg-blue-500 rounded-full" />
<div class="h-1 bg-blue-300 rounded-full" />
<div class="h-1 bg-blue-200 rounded-full" />
</div>
<ul class="text-sm text-gray-600 dark:text-gray-400 space-y-1">
<li v-for="feature in circle.features" :key="feature" class="flex items-start">
<span class="mr-2"></span>
<span>{{ feature }}</span>
</li>
</ul>
</div>
</UCard>
</div>
</UContainer>
</section>
<!-- Why Join? --> <div class="ml-12 relative">
<section class="py-20 bg-white dark:bg-gray-900"> <div
<UContainer> class="absolute -left-4 top-0 w-32 h-px bg-whisper-500 opacity-30 transform rotate-12"
<div class="text-center max-w-4xl mx-auto"> />
<h2 class="text-4xl font-bold text-blue-600 dark:text-blue-400 mb-12">
Why Join?
</h2>
<div class="bg-blue-50 dark:bg-blue-900/20 rounded-2xl p-12 border border-blue-200 dark:border-blue-800">
<div class="max-w-2xl mx-auto">
<div class="h-2 bg-blue-500 rounded-full mb-8" />
<p class="text-lg text-gray-700 dark:text-gray-300 leading-relaxed">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
</p>
</div>
</div>
</div>
</UContainer>
</section>
<div class="max-w-2xl">
<p class="text-stone-300 leading-loose text-lg mb-8">
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
</p>
<p class="text-stone-400 leading-relaxed ml-8">
Sed do eiusmod tempor incididunt ut labore et dolore magna
aliqua.<br />
Ut enim ad minim veniam, quis nostrud exercitation.
</p>
</div>
<div
class="absolute -bottom-8 right-0 text-6xl text-stone-800 opacity-20 font-bold"
>
?
</div>
</div>
</section>
</div> </div>
</template> </template>
<script setup> <script setup>
import { getCircleOptions } from '~/config/circles' import { getCircleOptions } from "~/config/circles";
const circles = getCircleOptions() const circles = getCircleOptions();
</script> </script>

View file

@ -1,10 +1,10 @@
<template> <template>
<div> <div>
<!-- Page Header --> <!-- Page Header -->
<PageHeader <PageHeader
title="Join" title="Join Ghost Guild"
subtitle="Become a member of our cooperative community and start building the future of game development together" subtitle="Become a member of our community and start building a more worker-centric future for games."
theme="blue" theme="gray"
size="large" size="large"
/> />
@ -12,15 +12,115 @@
<section class="py-20 bg-white dark:bg-gray-900"> <section class="py-20 bg-white dark:bg-gray-900">
<UContainer class="max-w-4xl"> <UContainer class="max-w-4xl">
<div class="text-center mb-12"> <div class="text-center mb-12">
<h2 class="text-3xl font-bold text-blue-600 dark:text-blue-400 mb-4"> <h2 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">
Membership Sign Up Membership Sign Up
</h2> </h2>
<p class="text-gray-600 dark:text-gray-300"> <p class="text-lg text-gray-700 dark:text-gray-300">
Choose your circle and contribution level to get started Choose your circle to connect with others at your stage. Choose your
contribution based on what you can afford. Everyone gets full
access.
</p> </p>
</div> </div>
<div class="bg-white dark:bg-gray-800 rounded-2xl p-8 shadow-xl border border-blue-200 dark:border-blue-800"> <!-- Step Indicators -->
<div class="flex justify-center mb-8">
<div class="flex items-center space-x-4">
<div class="flex items-center">
<div
:class="[
'w-10 h-10 rounded-full flex items-center justify-center font-semibold',
currentStep >= 1
? 'bg-gray-900 dark:bg-white text-white dark:text-gray-900'
: 'bg-gray-200 dark:bg-gray-700 text-gray-500',
]"
>
1
</div>
<span
class="ml-2 font-medium"
:class="
currentStep === 1
? 'text-gray-900 dark:text-white'
: 'text-gray-500'
"
>
Information
</span>
</div>
<div
v-if="needsPayment"
class="w-16 h-1 bg-gray-200 dark:bg-gray-700"
>
<div
class="h-full bg-gray-900 dark:bg-white transition-all"
:style="{ width: currentStep >= 2 ? '100%' : '0%' }"
/>
</div>
<div v-if="needsPayment" class="flex items-center">
<div
:class="[
'w-10 h-10 rounded-full flex items-center justify-center font-semibold',
currentStep >= 2
? 'bg-gray-900 dark:bg-white text-white dark:text-gray-900'
: 'bg-gray-200 dark:bg-gray-700 text-gray-500',
]"
>
2
</div>
<span
class="ml-2 font-medium"
:class="
currentStep === 2
? 'text-gray-900 dark:text-white'
: 'text-gray-500'
"
>
Payment
</span>
</div>
<div class="w-16 h-1 bg-gray-200 dark:bg-gray-700">
<div
class="h-full bg-gray-900 dark:bg-white transition-all"
:style="{ width: currentStep >= 3 ? '100%' : '0%' }"
/>
</div>
<div class="flex items-center">
<div
:class="[
'w-10 h-10 rounded-full flex items-center justify-center font-semibold',
currentStep >= 3
? 'bg-gray-900 dark:bg-white text-white dark:text-gray-900'
: 'bg-gray-200 dark:bg-gray-700 text-gray-500',
]"
>
<span v-if="needsPayment">3</span>
<span v-else>2</span>
</div>
<span
class="ml-2 font-medium"
:class="
currentStep === 3
? 'text-gray-900 dark:text-white'
: 'text-gray-500'
"
>
Confirmation
</span>
</div>
</div>
</div>
<!-- Error Message -->
<div v-if="errorMessage" class="mb-6">
<UAlert color="red" :title="errorMessage" />
</div>
<!-- Step 1: Information -->
<div v-if="currentStep === 1" class="bg-white dark:bg-gray-800">
<UForm :state="form" class="space-y-8" @submit="handleSubmit"> <UForm :state="form" class="space-y-8" @submit="handleSubmit">
<!-- Personal Information --> <!-- Personal Information -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
@ -54,8 +154,8 @@
class="flex flex-col p-6 rounded-lg border-2 cursor-pointer transition-all hover:shadow-md" class="flex flex-col p-6 rounded-lg border-2 cursor-pointer transition-all hover:shadow-md"
:class=" :class="
form.circle === option.value form.circle === option.value
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20' ? 'border-gray-900 dark:border-white bg-gray-50 dark:bg-gray-800'
: 'border-gray-200 dark:border-gray-700 hover:border-blue-300' : 'border-gray-200 dark:border-gray-700 hover:border-gray-400 dark:hover:border-gray-500'
" "
> >
<input <input
@ -64,44 +164,33 @@
:value="option.value" :value="option.value"
name="circle" name="circle"
class="mb-3" class="mb-3"
> />
<div class="font-medium text-lg mb-2">{{ option.label }}</div> <div class="font-medium text-lg mb-2">{{ option.label }}</div>
<div class="text-sm text-gray-600 dark:text-gray-400"> <div class="text-sm text-gray-600 dark:text-gray-400">
{{ option.description }} {{ option.description }}
</div> </div>
</label> </label>
</div> </div>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-3 italic">
Not sure? Start with Community - you can always move.
</p>
</div> </div>
<!-- Contribution Selection --> <!-- Contribution Selection -->
<div> <div>
<h3 class="text-lg font-semibold mb-4">Choose Your Monthly Contribution</h3> <UFormField
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> label="Choose Your Monthly Contribution"
<label name="contributionTier"
v-for="option in contributionOptions" required
:key="option.value" >
class="flex flex-col p-6 rounded-lg border-2 cursor-pointer transition-all hover:shadow-md" <USelect
:class=" v-model="form.contributionTier"
form.contributionTier === option.value :items="contributionOptions"
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20' value-key="value"
: 'border-gray-200 dark:border-gray-700 hover:border-blue-300' size="xl"
" class="w-full"
> />
<input </UFormField>
v-model="form.contributionTier"
type="radio"
:value="option.value"
name="contributionTier"
class="mb-3"
>
<div class="font-medium text-lg mb-2">{{ option.label }}</div>
<ul class="text-sm text-gray-600 dark:text-gray-400 space-y-1">
<li v-for="feature in option.features.slice(0, 2)" :key="feature">
{{ feature }}
</li>
</ul>
</label>
</div>
</div> </div>
<!-- Submit Button --> <!-- Submit Button -->
@ -113,76 +202,188 @@
size="xl" size="xl"
class="px-12" class="px-12"
> >
Continue to Payment {{
needsPayment ? "Continue to Payment" : "Complete Registration"
}}
</UButton> </UButton>
</div> </div>
</UForm> </UForm>
</div> </div>
<!-- Step 2: Payment -->
<div
v-if="currentStep === 2"
class="bg-white dark:bg-gray-800 rounded-xl p-8"
>
<div class="mb-6">
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">
Payment Information
</h3>
<p class="text-gray-600 dark:text-gray-400">
You're signing up for the {{ selectedTier.label }} plan
</p>
<p class="text-lg font-semibold text-gray-900 dark:text-white mt-2">
${{ selectedTier.amount }} CAD / month
</p>
</div>
<!-- Payment Instructions -->
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-6 mb-6">
<p class="text-gray-700 dark:text-gray-300">
Click "Complete Payment" below to open the secure payment modal
and verify your payment method.
</p>
</div>
<!-- Action Buttons -->
<div class="flex justify-between pt-6">
<UButton
variant="outline"
size="lg"
@click="goBack"
:disabled="isSubmitting"
>
Back
</UButton>
<UButton size="lg" :loading="isSubmitting" @click="processPayment">
Complete Payment
</UButton>
</div>
</div>
<!-- Step 3: Confirmation -->
<div
v-if="currentStep === 3"
class="bg-white dark:bg-gray-800 rounded-xl p-8"
>
<div class="text-center">
<div
class="w-20 h-20 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center mx-auto mb-6"
>
<svg
class="w-10 h-10 text-green-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Welcome to Ghost Guild!
</h3>
<div v-if="successMessage" class="mb-6">
<UAlert color="green" :title="successMessage" />
</div>
<div
class="bg-gray-50 dark:bg-gray-700 rounded-lg p-6 mb-6 text-left"
>
<h4 class="font-semibold mb-3">Membership Details:</h4>
<dl class="space-y-2">
<div class="flex justify-between">
<dt class="text-gray-600 dark:text-gray-400">Name:</dt>
<dd class="font-medium">{{ form.name }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-600 dark:text-gray-400">Email:</dt>
<dd class="font-medium">{{ form.email }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-600 dark:text-gray-400">Circle:</dt>
<dd class="font-medium capitalize">{{ form.circle }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-600 dark:text-gray-400">
Contribution:
</dt>
<dd class="font-medium">{{ selectedTier.label }}</dd>
</div>
<div v-if="customerCode" class="flex justify-between">
<dt class="text-gray-600 dark:text-gray-400">Member ID:</dt>
<dd class="font-medium">{{ customerCode }}</dd>
</div>
</dl>
</div>
<p class="text-gray-600 dark:text-gray-400 mb-4">
We've sent a confirmation email to {{ form.email }} with your
membership details.
</p>
<div class="bg-gray-100 dark:bg-gray-800 rounded-lg p-4 mb-8">
<p class="text-gray-700 dark:text-gray-300 text-center">
You will be automatically redirected to your dashboard in a few
seconds...
</p>
</div>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<UButton to="/member/dashboard" size="lg" class="px-8">
Go to Dashboard Now
</UButton>
<UButton variant="outline" size="lg" @click="resetForm">
Register Another Member
</UButton>
</div>
</div>
</div>
</UContainer> </UContainer>
</section> </section>
<!-- Membership Benefits --> <!-- How Ghost Guild Works -->
<section class="py-20 bg-gray-50 dark:bg-gray-800"> <section class="py-20 bg-gray-50 dark:bg-gray-800">
<UContainer> <UContainer>
<div class="text-center mb-12"> <div class="max-w-4xl mx-auto">
<h2 class="text-3xl font-bold text-blue-600 dark:text-blue-400 mb-4"> <div class="text-center mb-12">
Membership Benefits <h2 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">
</h2> How Ghost Guild Works
<p class="text-gray-600 dark:text-gray-300 max-w-2xl mx-auto"> </h2>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Join our community and unlock these amazing benefits. <p class="text-lg text-gray-700 dark:text-gray-300">
</p> Every member gets everything. Your circle helps you find relevant
</div> content and peers. Your contribution helps sustain our solidarity
economy.
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
<div class="bg-white dark:bg-gray-900 rounded-xl p-8 shadow-lg">
<div class="w-12 h-12 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center mb-6">
<div class="w-6 h-6 bg-blue-500 rounded" />
</div>
<h3 class="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
Community Access
</h3>
<div class="space-y-2 mb-4">
<div class="h-1 bg-blue-500 rounded-full w-full" />
<div class="h-1 bg-blue-300 rounded-full w-3/4" />
<div class="h-1 bg-blue-200 rounded-full w-1/2" />
</div>
<p class="text-gray-600 dark:text-gray-400">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Access to forums and resources.
</p> </p>
</div> </div>
<div class="bg-white dark:bg-gray-900 rounded-xl p-8 shadow-lg"> <div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<div class="w-12 h-12 bg-emerald-100 dark:bg-emerald-900/30 rounded-lg flex items-center justify-center mb-6"> <!-- Full Access -->
<div class="w-6 h-6 bg-emerald-500" style="clip-path: polygon(50% 0%, 0% 100%, 100% 100%)" /> <div class="bg-white dark:bg-gray-900 rounded-xl p-6">
<h3
class="text-xl font-semibold mb-4 text-gray-900 dark:text-white"
>
Full Access
</h3>
<ul class="text-gray-700 dark:text-gray-300 space-y-2">
<li>Complete resource library</li>
<li>All workshops and events</li>
<li>Slack community</li>
<li>Voting rights</li>
<li>Peer support opportunities</li>
</ul>
</div> </div>
<h3 class="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
Learning Resources
</h3>
<div class="space-y-2 mb-4">
<div class="h-1 bg-emerald-500 rounded-full w-full" />
<div class="h-1 bg-emerald-300 rounded-full w-2/3" />
<div class="h-1 bg-emerald-200 rounded-full w-3/4" />
</div>
<p class="text-gray-600 dark:text-gray-400">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Educational content and workshops.
</p>
</div>
<div class="bg-white dark:bg-gray-900 rounded-xl p-8 shadow-lg md:col-span-2 lg:col-span-1"> <!-- Circle-Specific Guidance -->
<div class="w-12 h-12 bg-purple-100 dark:bg-purple-900/30 rounded-lg flex items-center justify-center mb-6"> <div class="bg-white dark:bg-gray-900 rounded-xl p-6">
<div class="w-6 h-6 bg-purple-500 rounded-full" /> <h3
class="text-xl font-semibold mb-4 text-gray-900 dark:text-white"
>
Circle-Specific Guidance
</h3>
<ul class="text-gray-700 dark:text-gray-300 space-y-2">
<li>Curated resources for your stage</li>
<li>Connection with peers on similar journeys</li>
<li>Relevant workshop recommendations</li>
<li>Targeted support for your challenges</li>
</ul>
</div> </div>
<h3 class="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
Network & Support
</h3>
<div class="space-y-2 mb-4">
<div class="h-1 bg-purple-500 rounded-full w-5/6" />
<div class="h-1 bg-purple-300 rounded-full w-full" />
<div class="h-1 bg-purple-200 rounded-full w-2/3" />
</div>
<p class="text-gray-600 dark:text-gray-400">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Connect with like-minded professionals.
</p>
</div> </div>
</div> </div>
</UContainer> </UContainer>
@ -191,76 +392,70 @@
<!-- How to Join --> <!-- How to Join -->
<section class="py-20 bg-white dark:bg-gray-900"> <section class="py-20 bg-white dark:bg-gray-900">
<UContainer> <UContainer>
<div class="text-center mb-16">
<h2 class="text-3xl font-bold text-blue-600 dark:text-blue-400 mb-4">
How to Join
</h2>
<p class="text-gray-600 dark:text-gray-300 max-w-2xl mx-auto">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Follow these simple steps to become a member.
</p>
</div>
<div class="max-w-4xl mx-auto"> <div class="max-w-4xl mx-auto">
<div class="space-y-12"> <div class="space-y-8">
<div class="flex flex-col md:flex-row items-center gap-8"> <div class="flex items-start gap-6">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<div class="w-16 h-16 bg-blue-500 rounded-full flex items-center justify-center text-white font-bold text-xl"> <div
class="w-12 h-12 bg-gray-900 dark:bg-white rounded-full flex items-center justify-center text-white dark:text-gray-900 font-bold text-xl"
>
1 1
</div> </div>
</div> </div>
<div class="flex-1 text-center md:text-left"> <div class="flex-1">
<div class="space-y-3 mb-4"> <h3
<div class="h-2 bg-blue-500 rounded-full max-w-md mx-auto md:mx-0" /> class="text-xl font-semibold mb-2 text-gray-900 dark:text-white"
<div class="h-2 bg-blue-300 rounded-full max-w-sm mx-auto md:mx-0" /> >
<div class="h-2 bg-blue-200 rounded-full max-w-xs mx-auto md:mx-0" /> Pick your circle
</div>
<h3 class="text-xl font-semibold mb-2 text-gray-900 dark:text-white">
Choose Your Circle
</h3> </h3>
<p class="text-gray-600 dark:text-gray-400"> <p class="text-gray-700 dark:text-gray-300">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Select the circle that matches your interests. Where are you in your co-op journey? Select based on where you
are in your cooperative journey - exploring, building, or
practicing. Not sure? Start with Community.
</p> </p>
</div> </div>
</div> </div>
<div class="flex flex-col md:flex-row items-center gap-8"> <div class="flex items-start gap-6">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<div class="w-16 h-16 bg-blue-500 rounded-full flex items-center justify-center text-white font-bold text-xl"> <div
class="w-12 h-12 bg-gray-900 dark:bg-white rounded-full flex items-center justify-center text-white dark:text-gray-900 font-bold text-xl"
>
2 2
</div> </div>
</div> </div>
<div class="flex-1 text-center md:text-left"> <div class="flex-1">
<div class="space-y-3 mb-4"> <h3
<div class="h-2 bg-blue-500 rounded-full max-w-lg mx-auto md:mx-0" /> class="text-xl font-semibold mb-2 text-gray-900 dark:text-white"
<div class="h-2 bg-blue-300 rounded-full max-w-md mx-auto md:mx-0" /> >
<div class="h-2 bg-blue-200 rounded-full max-w-sm mx-auto md:mx-0" /> Choose your contribution
</div>
<h3 class="text-xl font-semibold mb-2 text-gray-900 dark:text-white">
Set Your Contribution
</h3> </h3>
<p class="text-gray-600 dark:text-gray-400"> <p class="text-gray-700 dark:text-gray-300">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Choose a contribution level based on your means. What can you afford? ($0-50+/month) Choose based on your
financial capacity. From $0 for those who need support to $50+
for those who can sponsor others. You can adjust anytime.
</p> </p>
</div> </div>
</div> </div>
<div class="flex flex-col md:flex-row items-center gap-8"> <div class="flex items-start gap-6">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<div class="w-16 h-16 bg-blue-500 rounded-full flex items-center justify-center text-white font-bold text-xl"> <div
class="w-12 h-12 bg-gray-900 dark:bg-white rounded-full flex items-center justify-center text-white dark:text-gray-900 font-bold text-xl"
>
3 3
</div> </div>
</div> </div>
<div class="flex-1 text-center md:text-left"> <div class="flex-1">
<div class="space-y-3 mb-4"> <h3
<div class="h-2 bg-blue-500 rounded-full max-w-sm mx-auto md:mx-0" /> class="text-xl font-semibold mb-2 text-gray-900 dark:text-white"
<div class="h-2 bg-blue-300 rounded-full max-w-lg mx-auto md:mx-0" /> >
<div class="h-2 bg-blue-200 rounded-full max-w-md mx-auto md:mx-0" /> Join the community
</div>
<h3 class="text-xl font-semibold mb-2 text-gray-900 dark:text-white">
Complete Registration
</h3> </h3>
<p class="text-gray-600 dark:text-gray-400"> <p class="text-gray-700 dark:text-gray-300">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Finalize your membership and start participating. Get instant access to everything. Fill out your profile, agree
to our community guidelines, and complete payment (if
applicable). You'll get instant access to our community.
</p> </p>
</div> </div>
</div> </div>
@ -268,90 +463,278 @@
</div> </div>
</UContainer> </UContainer>
</section> </section>
<!-- Ready to Join CTA -->
<section class="py-20 bg-blue-50 dark:bg-blue-900/20">
<UContainer>
<div class="text-center max-w-3xl mx-auto">
<h2 class="text-3xl font-bold text-blue-600 dark:text-blue-400 mb-8">
Ready to Join?
</h2>
<div class="flex flex-col md:flex-row items-center justify-center gap-8 mb-8">
<div class="bg-white dark:bg-gray-800 rounded-2xl p-8 shadow-lg border border-blue-200 dark:border-blue-800 flex-1 max-w-md">
<div class="text-2xl font-bold text-blue-600 dark:text-blue-400 mb-2">
Start Today
</div>
<p class="text-gray-600 dark:text-gray-300 mb-4">
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
</p>
<div class="space-y-2">
<div class="h-1 bg-blue-500 rounded-full" />
<div class="h-1 bg-blue-300 rounded-full w-3/4" />
<div class="h-1 bg-blue-200 rounded-full w-1/2" />
</div>
</div>
<div class="flex flex-col items-center gap-4">
<UButton
to="#membership-form"
size="xl"
color="primary"
class="px-8 py-4"
>
Join Now
</UButton>
<div class="h-2 bg-blue-500 rounded-full w-32" />
</div>
</div>
<p class="text-gray-600 dark:text-gray-300">
Questions? Contact us at <a href="mailto:hello@ghostguild.org" class="text-blue-600 dark:text-blue-400 hover:underline">hello@ghostguild.org</a>
</p>
</div>
</UContainer>
</section>
</div> </div>
</template> </template>
<script setup> <script setup>
import { reactive, ref, computed } from 'vue' import { reactive, ref, computed, onMounted, onUnmounted } from "vue";
import { getCircleOptions } from '~/config/circles' import { getCircleOptions } from "~/config/circles";
import { getContributionOptions } from '~/config/contributions' import {
getContributionOptions,
requiresPayment,
getContributionTierByValue,
} from "~/config/contributions";
// Form state // Form state
const form = reactive({ const form = reactive({
email: '', email: "",
name: '', name: "",
circle: 'community', circle: "community",
contributionTier: '15', contributionTier: "15",
}) billingAddress: {
street: "",
city: "",
province: "",
postalCode: "",
country: "CA",
},
});
// UI state // UI state
const isSubmitting = ref(false) const isSubmitting = ref(false);
const currentStep = ref(1); // 1: Info, 2: Billing (paid only), 3: Payment, 4: Confirmation
const errorMessage = ref("");
const successMessage = ref("");
// Helcim state
const customerId = ref(null);
const customerCode = ref(null);
const subscriptionData = ref(null);
const paymentToken = ref(null);
// Circle options from central config // Circle options from central config
const circleOptions = getCircleOptions() const circleOptions = getCircleOptions();
// Contribution options from central config // Contribution options from central config
const contributionOptions = getContributionOptions() const contributionOptions = getContributionOptions();
// Initialize composables
const {
initializeHelcimPay,
verifyPayment,
cleanup: cleanupHelcimPay,
} = useHelcimPay();
const { checkMemberStatus } = useAuth();
// Form validation // Form validation
const isFormValid = computed(() => { const isFormValid = computed(() => {
return form.name && form.email && form.circle && form.contributionTier return form.name && form.email && form.circle && form.contributionTier;
}) });
// Form submission - redirect to detailed form // Check if payment is required
const needsPayment = computed(() => {
return requiresPayment(form.contributionTier);
});
// Get selected tier info
const selectedTier = computed(() => {
return getContributionTierByValue(form.contributionTier);
});
// Step 1: Create customer
const handleSubmit = async () => { const handleSubmit = async () => {
if (isSubmitting.value) return if (isSubmitting.value || !isFormValid.value) return;
// For now, just scroll to the form or redirect to detailed signup isSubmitting.value = true;
const formElement = document.getElementById('membership-form') errorMessage.value = "";
if (formElement) {
formElement.scrollIntoView({ behavior: 'smooth' }) try {
} else { // Create customer in Helcim
// Could redirect to a detailed form page const response = await $fetch("/api/helcim/customer", {
await navigateTo('/join/details') method: "POST",
body: {
name: form.name,
email: form.email,
circle: form.circle,
contributionTier: form.contributionTier,
billingAddress: form.billingAddress,
},
});
if (response.success) {
console.log("Customer response:", response);
customerId.value = response.customerId;
customerCode.value = response.customerCode;
// Token is now set as httpOnly cookie by the server
// No need to manually set cookie on client side
// Move to next step
if (needsPayment.value) {
currentStep.value = 2;
// Debug log
console.log(
"Customer ID:",
customerId.value,
"Customer Code:",
customerCode.value,
);
// Initialize HelcimPay.js session for card verification
await initializeHelcimPay(customerId.value, customerCode.value, 0);
} else {
// For free tier, create subscription directly
await createSubscription();
// Check member status to ensure user is properly authenticated
await checkMemberStatus();
// Automatically redirect to dashboard after a short delay
setTimeout(() => {
navigateTo("/member/dashboard");
}, 3000); // 3 second delay to show success message
}
}
} catch (error) {
console.error("Error creating customer:", error);
errorMessage.value =
error.data?.message || "Failed to create account. Please try again.";
} finally {
isSubmitting.value = false;
} }
} };
</script>
// Step 2: Process payment
const processPayment = async () => {
if (isSubmitting.value) return;
console.log("Starting payment process...");
isSubmitting.value = true;
errorMessage.value = "";
try {
console.log("Calling verifyPayment()...");
// Verify payment through HelcimPay.js
const paymentResult = await verifyPayment();
console.log("Payment result from HelcimPay:", paymentResult);
if (paymentResult.success) {
paymentToken.value = paymentResult.cardToken;
console.log("Payment successful, cardToken:", paymentResult.cardToken);
console.log("Calling verify-payment endpoint...");
// Verify payment on server
const verifyResult = await $fetch("/api/helcim/verify-payment", {
method: "POST",
body: {
cardToken: paymentResult.cardToken,
customerId: customerId.value,
},
});
console.log("Payment verification result:", verifyResult);
console.log("Calling createSubscription...");
// Create subscription (don't let subscription errors prevent form progression)
const subscriptionResult = await createSubscription(
paymentResult.cardToken,
);
if (!subscriptionResult || !subscriptionResult.success) {
console.warn(
"Subscription creation failed but payment succeeded:",
subscriptionResult?.error,
);
// Still progress to success page since payment worked
currentStep.value = 3;
successMessage.value =
"Payment successful! Subscription setup may need manual completion.";
}
}
} catch (error) {
console.error("Payment process error:", error);
console.error("Error details:", {
message: error.message,
statusCode: error.statusCode,
statusMessage: error.statusMessage,
data: error.data,
});
errorMessage.value =
error.message || "Payment verification failed. Please try again.";
} finally {
isSubmitting.value = false;
}
};
// Create subscription
const createSubscription = async (cardToken = null) => {
try {
console.log("Creating subscription with:", {
customerId: customerId.value,
contributionTier: form.contributionTier,
cardToken: cardToken ? "present" : "null",
});
const response = await $fetch("/api/helcim/subscription", {
method: "POST",
body: {
customerId: customerId.value,
customerCode: customerCode.value,
contributionTier: form.contributionTier,
cardToken: cardToken,
},
});
console.log("Subscription creation response:", response);
if (response.success) {
subscriptionData.value = response.subscription;
console.log("Moving to step 3 - success!");
currentStep.value = 3;
successMessage.value = "Your membership has been activated successfully!";
// Check member status to ensure user is properly authenticated
await checkMemberStatus();
// Automatically redirect to dashboard after a short delay
setTimeout(() => {
navigateTo("/member/dashboard");
}, 3000); // 3 second delay to show success message
} else {
throw new Error("Subscription creation failed - response not successful");
}
} catch (error) {
console.error("Subscription creation error:", error);
console.error("Error details:", {
message: error.message,
statusCode: error.statusCode,
statusMessage: error.statusMessage,
data: error.data,
});
console.error(
"Subscription creation completely failed, but payment was successful",
);
// Don't throw error - let the calling function handle progression
return {
success: false,
error:
error.data?.message || error.message || "Failed to create subscription",
};
}
};
// Go back to previous step
const goBack = () => {
if (currentStep.value > 1) {
currentStep.value--;
errorMessage.value = "";
}
};
// Reset form
const resetForm = () => {
currentStep.value = 1;
customerId.value = null;
customerCode.value = null;
subscriptionData.value = null;
paymentToken.value = null;
errorMessage.value = "";
successMessage.value = "";
form.email = "";
form.name = "";
form.circle = "community";
form.contributionTier = "15";
};
// Cleanup on unmount
onUnmounted(() => {
cleanupHelcimPay();
});
</script>

View file

@ -0,0 +1,639 @@
<template>
<div>
<!-- Page Header -->
<PageHeader
title="Member Dashboard"
:subtitle="`Welcome back, ${memberData?.name || 'Member'}!`"
theme="blue"
size="medium"
/>
<UContainer class="py-12">
<!-- Loading State -->
<div
v-if="!memberData || authPending"
class="flex justify-center items-center py-20"
>
<div class="text-center">
<div
class="w-8 h-8 border-4 border-whisper-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"
></div>
<p class="text-stone-300">Loading your dashboard...</p>
</div>
</div>
<!-- Dashboard Content -->
<div v-else class="space-y-8">
<!-- Welcome Card -->
<UCard
class="sparkle-field"
:ui="{
root: 'bg-ghost-900 border border-ghost-700',
header: 'border-b border-ghost-700',
body: 'bg-ghost-900',
}"
>
<template #header>
<div class="flex items-start justify-between gap-4">
<div class="flex-1">
<h1 class="text-2xl font-bold text-stone-100 ethereal-text">
Welcome to Ghost Guild, {{ memberData?.name }}!
</h1>
<p class="text-stone-300 mt-2">
Your membership is active and you're part of our cooperative
community.
</p>
</div>
<div class="flex-shrink-0" v-if="memberData?.avatar">
<img
:src="`/ghosties/Ghost-${capitalize(memberData.avatar)}.png`"
:alt="memberData.name"
class="w-16 h-16"
/>
</div>
<div v-else class="flex-shrink-0">
<div
class="w-16 h-16 bg-ghost-700 border border-ghost-600 flex items-center justify-center text-stone-200 font-bold text-xl"
>
{{ memberData?.name?.charAt(0)?.toUpperCase() }}
</div>
</div>
</div>
</template>
<div class="flex flex-wrap gap-4 text-sm">
<div class="bg-ghost-800 border border-ghost-600 px-4 py-2">
<span class="text-stone-400">Circle:</span>
<span class="font-medium text-whisper-300 ml-1 capitalize">{{
memberData?.circle
}}</span>
</div>
<div class="bg-ghost-800 border border-ghost-600 px-4 py-2">
<span class="text-stone-400">Contribution:</span>
<span class="font-medium text-whisper-300 ml-1"
>${{ memberData?.contributionTier }} CAD/month</span
>
</div>
</div>
</UCard>
<!-- Quick Links -->
<UCard
:ui="{
root: 'bg-ghost-900 border border-ghost-700',
header: 'border-b border-ghost-700 bg-ghost-900',
body: 'bg-ghost-900',
}"
>
<template #header>
<h2 class="text-xl font-bold text-stone-100 ethereal-text">
Quick Links
</h2>
</template>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<UButton
to="/member/my-updates"
variant="outline"
class="border-ghost-600 text-stone-200 hover:bg-ghost-800 hover:border-whisper-500 justify-start"
block
>
<template #leading>
<Icon name="heroicons:pencil-square" class="w-5 h-5" />
</template>
Post an Update
</UButton>
<UButton
disabled
variant="outline"
class="border-ghost-600 text-stone-500 cursor-not-allowed justify-start"
block
title="Coming soon"
>
<template #leading>
<Icon name="heroicons:calendar-days" class="w-5 h-5" />
</template>
Propose an Event
</UButton>
<UButton
disabled
variant="outline"
class="border-ghost-600 text-stone-500 cursor-not-allowed justify-start"
block
title="Coming soon"
>
<template #leading>
<Icon name="heroicons:user-group" class="w-5 h-5" />
</template>
Book a Peer Session
</UButton>
<UButton
disabled
variant="outline"
class="border-ghost-600 text-stone-500 cursor-not-allowed justify-start"
block
title="Coming soon"
>
<template #leading>
<Icon name="heroicons:book-open" class="w-5 h-5" />
</template>
Browse Resources
</UButton>
<UButton
to="/member/profile"
variant="outline"
class="border-ghost-600 text-stone-200 hover:bg-ghost-800 hover:border-whisper-500 justify-start"
block
>
<template #leading>
<Icon name="heroicons:user-circle" class="w-5 h-5" />
</template>
Update Profile
</UButton>
</div>
</UCard>
<!-- Quick Actions Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<UCard
:ui="{
root: 'bg-ghost-900 border border-ghost-700 hover:border-whisper-600 transition-colors',
header: 'border-b-0',
body: 'bg-ghost-900',
footer: 'border-t-0 bg-ghost-900',
}"
class="hover:border-whisper-600 transition-colors"
>
<template #header>
<div
class="w-12 h-12 bg-ghost-800 border border-ghost-600 flex items-center justify-center"
>
<Icon
name="heroicons:calendar-days"
class="w-6 h-6 text-whisper-400"
/>
</div>
</template>
<h3 class="text-lg font-semibold mb-2 text-stone-100">
Upcoming Events
</h3>
<p class="text-stone-300 mb-4">
Discover and register for community events and workshops.
</p>
<template #footer>
<UButton
to="/events"
variant="outline"
size="sm"
class="border-ghost-600 text-stone-200 hover:bg-ghost-800 hover:border-whisper-500"
>
View Events
</UButton>
</template>
</UCard>
<UCard
:ui="{
root: 'bg-ghost-900 border border-ghost-700 hover:border-whisper-600 transition-colors',
header: 'border-b-0',
body: 'bg-ghost-900',
footer: 'border-t-0 bg-ghost-900',
}"
>
<template #header>
<div
class="w-12 h-12 bg-ghost-800 border border-ghost-600 flex items-center justify-center"
>
<Icon
name="heroicons:user-group"
class="w-6 h-6 text-whisper-400"
/>
</div>
</template>
<h3 class="text-lg font-semibold mb-2 text-stone-100">Community</h3>
<p class="text-stone-300 mb-4">
Connect with other members in your circle and beyond.
</p>
<template #footer>
<UButton
to="/members"
variant="outline"
size="sm"
class="border-ghost-600 text-stone-200 hover:bg-ghost-800 hover:border-whisper-500"
>
Browse Members
</UButton>
</template>
</UCard>
<UCard
:ui="{
root: 'bg-ghost-900 border border-ghost-700 hover:border-whisper-600 transition-colors',
header: 'border-b-0',
body: 'bg-ghost-900',
footer: 'border-t-0 bg-ghost-900',
}"
>
<template #header>
<div
class="w-12 h-12 bg-ghost-800 border border-ghost-600 flex items-center justify-center"
>
<Icon
name="heroicons:cog-6-tooth"
class="w-6 h-6 text-whisper-400"
/>
</div>
</template>
<h3 class="text-lg font-semibold mb-2 text-stone-100">
Account Settings
</h3>
<p class="text-stone-300 mb-4">
Manage your profile and membership settings.
</p>
<template #footer>
<UButton
to="/member/profile#account"
variant="outline"
size="sm"
class="border-ghost-600 text-stone-200 hover:bg-ghost-800 hover:border-whisper-500"
>
Manage Account
</UButton>
</template>
</UCard>
</div>
<!-- Your Registered Events -->
<UCard
:ui="{
root: 'bg-ghost-900 border border-ghost-700',
header: 'border-b border-ghost-700 bg-ghost-900',
body: 'bg-ghost-900',
}"
>
<template #header>
<div class="flex items-center justify-between">
<h2 class="text-xl font-bold text-stone-100 ethereal-text">
Your Upcoming Events
</h2>
<UButton
to="/events"
variant="ghost"
size="sm"
class="text-stone-300 hover:text-stone-100"
>
Browse All Events
</UButton>
</div>
</template>
<div v-if="loadingEvents" class="text-center py-8">
<div
class="w-6 h-6 border-2 border-whisper-500 border-t-transparent rounded-full animate-spin mx-auto"
></div>
</div>
<div v-else-if="registeredEvents.length" class="space-y-4">
<NuxtLink
v-for="evt in registeredEvents"
:key="evt._id"
:to="`/events/${evt.slug || evt._id}`"
class="block p-4 border border-ghost-700 hover:border-whisper-500 transition-colors"
>
<div class="flex items-start gap-4">
<div
v-if="
evt.featureImage &&
(evt.featureImage.publicId || evt.featureImage.url)
"
class="flex-shrink-0 w-20 h-20 overflow-hidden"
>
<img
:src="getEventImageUrl(evt.featureImage)"
:alt="evt.title"
class="w-full h-full object-cover"
/>
</div>
<div
v-else
class="flex-shrink-0 w-20 h-20 bg-ghost-800 border border-ghost-600 flex items-center justify-center"
>
<Icon
name="heroicons:calendar-days"
class="w-8 h-8 text-whisper-400"
/>
</div>
<div class="flex-1 min-w-0">
<h3 class="font-semibold text-stone-100 mb-1">
{{ evt.title }}
</h3>
<div class="flex items-center gap-4 text-sm text-stone-400">
<span class="flex items-center gap-1">
<Icon name="heroicons:calendar" class="w-4 h-4" />
{{ formatEventDate(evt.startDate) }}
</span>
<span class="flex items-center gap-1">
<Icon name="heroicons:clock" class="w-4 h-4" />
{{ formatEventTime(evt.startDate) }}
</span>
</div>
</div>
<div class="flex-shrink-0">
<Icon
name="heroicons:chevron-right"
class="w-5 h-5 text-stone-500"
/>
</div>
</div>
</NuxtLink>
</div>
<div v-else class="text-center py-8">
<Icon
name="heroicons:calendar-days"
class="w-12 h-12 text-stone-600 mx-auto mb-3"
/>
<p class="text-stone-400 mb-4">
You haven't registered for any upcoming events
</p>
<UButton
to="/events"
size="sm"
class="border-ghost-600 text-stone-200 hover:bg-ghost-800 hover:border-whisper-500"
>
Browse Events
</UButton>
</div>
</UCard>
<!-- Community Pulse - Recent Updates -->
<UCard
class="sparkle-field"
:ui="{
root: 'bg-ghost-900 border border-ghost-700',
header: 'border-b border-ghost-700 bg-ghost-900',
body: 'bg-ghost-900',
}"
>
<template #header>
<div class="flex items-center justify-between">
<h2 class="text-xl font-bold text-stone-100 ethereal-text">
Community Pulse
</h2>
<UButton
to="/updates"
variant="ghost"
size="sm"
class="text-stone-300 hover:text-stone-100"
>
View All
</UButton>
</div>
</template>
<div v-if="loadingUpdates" class="text-center py-8">
<div
class="w-6 h-6 border-2 border-whisper-500 border-t-transparent rounded-full animate-spin mx-auto"
></div>
</div>
<div v-else-if="recentUpdates.length" class="space-y-4">
<div
v-for="update in recentUpdates"
:key="update._id"
class="border-l-2 border-ghost-600 pl-4 py-2"
>
<div class="flex items-start gap-3">
<img
v-if="
update.author?.avatar && isValidAvatar(update.author.avatar)
"
:src="`/ghosties/Ghost-${capitalize(update.author.avatar)}.png`"
:alt="update.author.name"
class="w-8 h-8 flex-shrink-0"
/>
<div
v-else-if="update.author?.name"
class="w-8 h-8 bg-ghost-700 border border-ghost-600 flex items-center justify-center text-stone-200 text-xs font-bold flex-shrink-0"
>
{{ update.author.name.charAt(0).toUpperCase() }}
</div>
<div class="flex-1 min-w-0">
<div class="flex items-baseline gap-2 mb-1">
<span class="font-semibold text-stone-100 text-sm">
{{ update.author?.name }}
</span>
<span class="text-xs text-stone-500">
{{ formatTimeAgo(update.createdAt) }}
</span>
</div>
<p class="text-stone-300 text-sm line-clamp-2">
{{ update.content }}
</p>
<NuxtLink
:to="`/updates/${update._id}`"
class="text-xs text-whisper-400 hover:text-whisper-300 mt-1 inline-block"
>
Read more
</NuxtLink>
</div>
</div>
</div>
</div>
<div v-else class="text-center py-8">
<p class="text-stone-400 mb-4">No community updates yet</p>
<UButton
to="/updates/new"
size="sm"
variant="outline"
class="border-ghost-600 text-stone-200 hover:bg-ghost-800 hover:border-whisper-500"
>
Post the First Update
</UButton>
</div>
</UCard>
</div>
</UContainer>
</div>
</template>
<script setup>
const { memberData, checkMemberStatus } = useAuth();
const recentUpdates = ref([]);
const loadingUpdates = ref(false);
const registeredEvents = ref([]);
const loadingEvents = ref(false);
// Handle authentication check on page load
const { pending: authPending } = await useLazyAsyncData(
"dashboard-auth",
async () => {
// Only check authentication on client side
if (process.server) return null;
console.log(
"📊 Dashboard auth check - memberData exists:",
!!memberData.value,
);
// If no member data, try to authenticate
if (!memberData.value) {
console.log(" - No member data, checking authentication...");
const isAuthenticated = await checkMemberStatus();
console.log(" - Auth result:", isAuthenticated);
if (!isAuthenticated) {
console.log(" - Redirecting to login");
await navigateTo("/login");
return null;
}
}
console.log(" - ✅ Dashboard auth successful");
return memberData.value;
},
);
// Load recent updates
const loadRecentUpdates = async () => {
loadingUpdates.value = true;
try {
const response = await $fetch("/api/updates", {
params: { limit: 5, skip: 0 },
});
recentUpdates.value = response.updates;
} catch (error) {
console.error("Failed to load recent updates:", error);
} finally {
loadingUpdates.value = false;
}
};
// Load registered events
const loadRegisteredEvents = async () => {
console.log(
"🔍 memberData.value:",
JSON.stringify(memberData.value, null, 2),
);
console.log("🔍 memberData.value._id:", memberData.value?._id);
console.log("🔍 memberData.value.id:", memberData.value?.id);
const memberId = memberData.value?._id || memberData.value?.id;
if (!memberId) {
console.log("❌ No member ID available");
return;
}
console.log("📅 Loading events for member:", memberId);
loadingEvents.value = true;
try {
const response = await $fetch("/api/members/my-events", {
params: { memberId },
});
console.log("📅 Events response:", response);
registeredEvents.value = response.events;
} catch (error) {
console.error("Failed to load registered events:", error);
} finally {
loadingEvents.value = false;
}
};
// Valid ghost avatar options
const validAvatars = [
"disbelieving",
"double-take",
"exasperated",
"mild",
"sweet",
];
const isValidAvatar = (avatar) => {
if (!avatar) return false;
return validAvatars.includes(avatar.toLowerCase());
};
const capitalize = (str) => {
if (!str) return "";
// Handle kebab-case or multi-word avatars (e.g., "double-take" -> "Double-Take")
return str
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join("-");
};
const formatTimeAgo = (date) => {
const now = new Date();
const updateDate = new Date(date);
const diffInSeconds = Math.floor((now - updateDate) / 1000);
if (diffInSeconds < 60) return "just now";
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago`;
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago`;
if (diffInSeconds < 604800)
return `${Math.floor(diffInSeconds / 86400)}d ago`;
return updateDate.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
};
// Helper functions for event display
const getEventImageUrl = (featureImage) => {
if (!featureImage) return "";
if (featureImage.url) {
return featureImage.url;
}
if (featureImage.publicId) {
const config = useRuntimeConfig();
return `https://res.cloudinary.com/${config.public.cloudinaryCloudName}/image/upload/w_200,h_200,c_fill,f_auto,q_auto/${featureImage.publicId}`;
}
return "";
};
const formatEventDate = (dateString) => {
const date = new Date(dateString);
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
}).format(date);
};
const formatEventTime = (dateString) => {
const date = new Date(dateString);
return new Intl.DateTimeFormat("en-US", {
hour: "numeric",
minute: "2-digit",
}).format(date);
};
onMounted(() => {
loadRecentUpdates();
loadRegisteredEvents();
});
// Set page meta
useHead({
title: "Member Dashboard - Ghost Guild",
});
// Removed middleware - handling auth directly in the page component
</script>

View file

@ -0,0 +1,211 @@
<template>
<div>
<PageHeader
title="My Updates"
subtitle="View and manage your updates"
theme="stone"
size="medium"
/>
<section class="py-12 px-4">
<UContainer class="px-4">
<!-- Stats -->
<div v-if="!pending" class="mb-8 flex items-center justify-between">
<div class="text-stone-300">
<span class="text-2xl font-bold text-stone-100">{{ total }}</span>
{{ total === 1 ? "update" : "updates" }} posted
</div>
<UButton to="/updates/new" icon="i-lucide-plus"> New Update </UButton>
</div>
<!-- Loading State -->
<div
v-if="pending && !updates.length"
class="flex justify-center items-center py-20"
>
<div class="text-center">
<div
class="w-8 h-8 border-4 border-stone-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"
></div>
<p class="text-stone-400">Loading your updates...</p>
</div>
</div>
<!-- Updates List -->
<div v-else-if="updates.length" class="space-y-6">
<UpdateCard
v-for="update in updates"
:key="update._id"
:update="update"
:show-preview="true"
@edit="handleEdit"
@delete="handleDelete"
/>
<!-- Load More -->
<div v-if="hasMore" class="flex justify-center pt-4">
<UButton
variant="outline"
color="neutral"
:loading="loadingMore"
@click="loadMore"
>
Load More
</UButton>
</div>
</div>
<!-- Empty State -->
<div v-else class="text-center py-20">
<div class="w-16 h-16 mx-auto mb-4 opacity-50">
<svg
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
class="text-stone-600"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
/>
</svg>
</div>
<h3 class="text-lg font-medium text-stone-300 mb-2">
No updates yet
</h3>
<p class="text-stone-400 mb-6">
Share your first update with the community
</p>
<UButton to="/updates/new" icon="i-lucide-plus">
Post Your First Update
</UButton>
</div>
</UContainer>
</section>
<!-- Delete Confirmation Modal -->
<UModal
v-model:open="showDeleteModal"
title="Delete Update?"
description="Are you sure you want to delete this update? This action cannot be undone."
>
<template #footer>
<div class="flex justify-end gap-3">
<UButton
variant="ghost"
color="neutral"
@click="showDeleteModal = false"
>
Cancel
</UButton>
<UButton color="red" :loading="deleting" @click="confirmDelete">
Delete
</UButton>
</div>
</template>
</UModal>
</div>
</template>
<script setup>
const { isAuthenticated, checkMemberStatus } = useAuth();
const updates = ref([]);
const pending = ref(false);
const loadingMore = ref(false);
const hasMore = ref(false);
const total = ref(0);
const showDeleteModal = ref(false);
const updateToDelete = ref(null);
const deleting = ref(false);
// Check authentication
onMounted(async () => {
if (!isAuthenticated.value) {
const authenticated = await checkMemberStatus();
if (!authenticated) {
await navigateTo("/login");
return;
}
}
await loadUpdates();
});
// Load updates
const loadUpdates = async () => {
pending.value = true;
try {
const response = await $fetch("/api/updates/my-updates", {
params: { limit: 20, skip: 0 },
});
updates.value = response.updates;
total.value = response.total;
hasMore.value = response.hasMore;
} catch (error) {
console.error("Failed to load updates:", error);
} finally {
pending.value = false;
}
};
// Load more updates
const loadMore = async () => {
loadingMore.value = true;
try {
const response = await $fetch("/api/updates/my-updates", {
params: { limit: 20, skip: updates.value.length },
});
updates.value.push(...response.updates);
hasMore.value = response.hasMore;
} catch (error) {
console.error("Failed to load more updates:", error);
} finally {
loadingMore.value = false;
}
};
// Handle edit
const handleEdit = (update) => {
navigateTo(`/updates/${update._id}/edit`);
};
// Handle delete
const handleDelete = (update) => {
updateToDelete.value = update;
showDeleteModal.value = true;
};
// Confirm delete
const confirmDelete = async () => {
if (!updateToDelete.value) return;
deleting.value = true;
try {
await $fetch(`/api/updates/${updateToDelete.value._id}`, {
method: "DELETE",
});
// Remove from list
updates.value = updates.value.filter(
(u) => u._id !== updateToDelete.value._id,
);
total.value--;
showDeleteModal.value = false;
updateToDelete.value = null;
} catch (error) {
console.error("Failed to delete update:", error);
alert("Failed to delete update. Please try again.");
} finally {
deleting.value = false;
}
};
useHead({
title: "My Updates - Ghost Guild",
});
</script>

1273
app/pages/member/profile.vue Normal file

File diff suppressed because it is too large Load diff

400
app/pages/members.vue Normal file
View file

@ -0,0 +1,400 @@
<template>
<div>
<PageHeader
title="Member Directory"
subtitle="Connect with members of the Ghost Guild community"
theme="purple"
size="medium"
/>
<section class="py-12 px-4">
<UContainer class="px-4">
<!-- Search and Filters -->
<div class="mb-8 space-y-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<!-- Search -->
<div class="md:col-span-2">
<UInput
v-model="searchQuery"
placeholder="Search by name or bio..."
icon="i-heroicons-magnifying-glass"
size="lg"
@input="debouncedSearch"
/>
</div>
<!-- Circle Filter -->
<USelect
v-model="selectedCircle"
:options="circleOptions"
placeholder="All Circles"
size="lg"
@change="loadMembers"
/>
</div>
<!-- Skills Filter -->
<div v-if="availableSkills.length > 0">
<div class="flex flex-wrap gap-2">
<span class="text-sm text-stone-400 mr-2 self-center"
>Filter by skill:</span
>
<button
v-for="skill in availableSkills.slice(
0,
showAllSkills ? undefined : 10,
)"
:key="skill"
type="button"
class="px-3 py-1 rounded-full text-sm transition-all border"
:class="
selectedSkills.includes(skill)
? 'bg-purple-500/20 text-purple-300 border-purple-500/50'
: 'bg-stone-800/50 text-stone-400 border-stone-700 hover:border-stone-600'
"
@click="toggleSkill(skill)"
>
{{ skill }}
</button>
<button
v-if="availableSkills.length > 10"
type="button"
class="px-3 py-1 text-sm text-purple-400 hover:text-purple-300"
@click="showAllSkills = !showAllSkills"
>
{{
showAllSkills
? "Show less"
: `+${availableSkills.length - 10} more`
}}
</button>
</div>
</div>
<!-- Active Filters -->
<div
v-if="selectedCircle || selectedSkills.length > 0"
class="flex items-center gap-2 text-sm"
>
<span class="text-stone-400">Active filters:</span>
<span
v-if="selectedCircle"
class="px-2 py-1 bg-purple-500/20 text-purple-300 rounded-full border border-purple-500/30 flex items-center gap-1"
>
{{ circleLabels[selectedCircle] }}
<button
type="button"
class="hover:text-purple-200"
@click="clearCircleFilter"
>
×
</button>
</span>
<button
v-if="selectedSkills.length > 0"
type="button"
class="text-purple-400 hover:text-purple-300"
@click="clearAllFilters"
>
Clear all filters
</button>
</div>
</div>
<!-- Loading State -->
<div
v-if="loading && !members.length"
class="flex justify-center items-center py-20"
>
<div class="text-center">
<div
class="w-8 h-8 border-4 border-purple-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"
></div>
<p class="text-stone-400">Loading members...</p>
</div>
</div>
<!-- Members List -->
<div v-else-if="members.length > 0">
<div class="mb-4 text-stone-400 text-sm">
{{ totalCount }} {{ totalCount === 1 ? "member" : "members" }} found
</div>
<div class="space-y-2">
<div
v-for="member in members"
:key="member._id"
class="backdrop-blur-sm bg-stone-900/50 border border-stone-700/50 rounded-lg p-4 hover:border-purple-500/50 transition-all group flex items-center gap-4"
>
<!-- Avatar -->
<div
class="w-12 h-12 rounded-lg bg-stone-800 border border-stone-700 flex items-center justify-center flex-shrink-0 group-hover:border-purple-500/50 transition-colors"
>
<img
v-if="member.avatar"
:src="`/ghosties/Ghost-${member.avatar.charAt(0).toUpperCase() + member.avatar.slice(1)}.png`"
:alt="member.name"
class="w-8 h-8 object-contain"
/>
<span v-else class="text-xl text-stone-600">👻</span>
</div>
<!-- Name and Info -->
<div class="flex-1 min-w-0">
<div class="flex items-baseline gap-2 flex-wrap">
<NuxtLink
:to="`/updates/user/${member._id}`"
class="font-semibold text-stone-100 hover:text-purple-300 transition-colors"
>
{{ member.name }}
</NuxtLink>
<span v-if="member.pronouns" class="text-sm text-stone-400">
{{ member.pronouns }}
</span>
<span
class="px-2 py-0.5 bg-purple-500/20 text-purple-300 rounded text-xs border border-purple-500/30"
>
{{ circleLabels[member.circle] }}
</span>
<span v-if="member.studio" class="text-sm text-stone-400">
{{ member.studio }}
</span>
<span v-if="member.location" class="text-sm text-stone-500">
{{ member.location }}
</span>
</div>
</div>
<!-- Social Links -->
<div
v-if="member.socialLinks && hasSocialLinks(member.socialLinks)"
class="flex gap-3 flex-shrink-0"
>
<a
v-if="member.socialLinks.mastodon"
:href="member.socialLinks.mastodon"
target="_blank"
rel="noopener noreferrer"
class="text-stone-400 hover:text-purple-400 transition-colors"
title="Mastodon"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path
d="M23.193 7.879c0-5.206-3.411-6.732-3.411-6.732C18.062.357 15.108.025 12.041 0h-.076c-3.068.025-6.02.357-7.74 1.147 0 0-3.411 1.526-3.411 6.732 0 1.192-.023 2.618.015 4.129.124 5.092.934 10.109 5.641 11.355 2.17.574 4.034.695 5.535.612 2.722-.15 4.25-.972 4.25-.972l-.09-1.975s-1.945.613-4.129.539c-2.165-.074-4.449-.233-4.799-2.891a5.499 5.499 0 0 1-.048-.745s2.125.52 4.817.643c1.646.075 3.19-.097 4.758-.283 3.007-.359 5.625-2.212 5.954-3.905.517-2.665.475-6.507.475-6.507zm-4.024 6.709h-2.497V8.469c0-1.29-.543-1.944-1.628-1.944-1.2 0-1.802.776-1.802 2.312v3.349h-2.483v-3.35c0-1.536-.602-2.312-1.802-2.312-1.085 0-1.628.655-1.628 1.944v6.119H4.832V8.284c0-1.289.328-2.313.987-3.07.68-.758 1.569-1.146 2.674-1.146 1.278 0 2.246.491 2.886 1.474L12 6.585l.622-1.043c.64-.983 1.608-1.474 2.886-1.474 1.104 0 1.994.388 2.674 1.146.658.757.986 1.781.986 3.07v6.304z"
/>
</svg>
</a>
<a
v-if="member.socialLinks.linkedin"
:href="member.socialLinks.linkedin"
target="_blank"
rel="noopener noreferrer"
class="text-stone-400 hover:text-purple-400 transition-colors"
title="LinkedIn"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path
d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"
/>
</svg>
</a>
<a
v-if="member.socialLinks.website"
:href="member.socialLinks.website"
target="_blank"
rel="noopener noreferrer"
class="text-stone-400 hover:text-purple-400 transition-colors"
title="Website"
>
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"
/>
</svg>
</a>
<a
v-if="member.socialLinks.other"
:href="member.socialLinks.other"
target="_blank"
rel="noopener noreferrer"
class="text-stone-400 hover:text-purple-400 transition-colors"
title="Other link"
>
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
/>
</svg>
</a>
</div>
</div>
</div>
</div>
<!-- Empty State -->
<div v-else class="text-center py-20">
<div class="w-16 h-16 mx-auto mb-4 opacity-50">
<svg
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
class="text-stone-600"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
</div>
<h3 class="text-lg font-medium text-stone-300 mb-2">
No members found
</h3>
<p class="text-stone-400 mb-6">
Try adjusting your search or filters
</p>
<UButton variant="outline" @click="clearAllFilters">
Clear Filters
</UButton>
</div>
<!-- Not Authenticated Notice -->
<div
v-if="!isAuthenticated && members.length > 0"
class="mt-8 backdrop-blur-sm bg-purple-500/10 border border-purple-500/30 rounded-lg p-6 text-center"
>
<p class="text-purple-200 mb-4">
🔒 Some member information is visible to members only
</p>
<div class="flex gap-3 justify-center">
<UButton to="/login" variant="outline"> Log In </UButton>
<UButton to="/join"> Join Ghost Guild </UButton>
</div>
</div>
</UContainer>
</section>
</div>
</template>
<script setup>
const { isAuthenticated } = useAuth();
// State
const members = ref([]);
const totalCount = ref(0);
const availableSkills = ref([]);
const loading = ref(false);
const searchQuery = ref("");
const selectedCircle = ref("");
const selectedSkills = ref([]);
const showAllSkills = ref(false);
// Circle options
const circleOptions = [
{ label: "All Circles", value: "" },
{ label: "Community", value: "community" },
{ label: "Founder", value: "founder" },
{ label: "Practitioner", value: "practitioner" },
];
const circleLabels = {
community: "Community",
founder: "Founder",
practitioner: "Practitioner",
};
// Helper to check if member has social links
const hasSocialLinks = (links) => {
if (!links) return false;
return !!(links.mastodon || links.linkedin || links.website || links.other);
};
// Load members
const loadMembers = async () => {
loading.value = true;
try {
const params = {};
if (searchQuery.value) params.search = searchQuery.value;
if (selectedCircle.value) params.circle = selectedCircle.value;
if (selectedSkills.value.length > 0)
params.skills = selectedSkills.value.join(",");
const data = await $fetch("/api/members/directory", { params });
members.value = data.members;
totalCount.value = data.totalCount;
availableSkills.value = data.filters.availableSkills;
} catch (error) {
console.error("Failed to load members:", error);
} finally {
loading.value = false;
}
};
// Debounced search
let searchTimeout;
const debouncedSearch = () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
loadMembers();
}, 300);
};
// Toggle skill filter
const toggleSkill = (skill) => {
const index = selectedSkills.value.indexOf(skill);
if (index > -1) {
selectedSkills.value.splice(index, 1);
} else {
selectedSkills.value.push(skill);
}
loadMembers();
};
// Clear filters
const clearCircleFilter = () => {
selectedCircle.value = "";
loadMembers();
};
const clearAllFilters = () => {
searchQuery.value = "";
selectedCircle.value = "";
selectedSkills.value = [];
loadMembers();
};
// Load on mount
onMounted(() => {
loadMembers();
});
useHead({
title: "Member Directory - Ghost Guild",
meta: [
{
name: "description",
content:
"Connect with members of the Ghost Guild community - game developers, founders, and practitioners building solidarity economy studios.",
},
],
});
</script>

View file

@ -1,68 +0,0 @@
<!-- pages/members/index.vue -->
<template>
<UDashboard>
<UDashboardPanel>
<UDashboardHeader>
<template #title> Welcome back, {{ member?.name }}! </template>
</UDashboardHeader>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<UCard>
<template #header>Your Circle</template>
<p class="text-xl font-semibold">{{ circleLabel }}</p>
<p class="text-sm text-zinc-600 mt-1">{{ circleDescription }}</p>
<UButton variant="soft" size="sm" class="mt-2">
Request Circle Change
</UButton>
</UCard>
<UCard>
<template #header>Your Contribution</template>
<p class="text-xl font-semibold">{{ contributionLabel }}</p>
<p class="text-sm text-zinc-600">Supporting 2 solidarity spots</p>
<UButton variant="soft" size="sm" class="mt-2">
Adjust Contribution
</UButton>
</UCard>
</div>
<UCard class="mt-6">
<template #header>Quick Links</template>
<UList>
<li><NuxtLink to="/members/resources">Resource Library</NuxtLink></li>
<li><a href="https://gamma-space.slack.com">Slack Community</a></li>
<li><NuxtLink to="/members/events">Upcoming Events</NuxtLink></li>
</UList>
</UCard>
</UDashboardPanel>
</UDashboard>
</template>
<script setup>
import { computed } from 'vue'
import { getCircleByValue } from '~/config/circles'
import { getContributionTierByValue } from '~/config/contributions'
// Mock member data - in real app this would come from authentication/API
const member = ref({
name: 'Guest User',
circle: 'community',
contributionTier: '15'
})
// Computed properties for display labels
const circleLabel = computed(() => {
const circle = getCircleByValue(member.value?.circle)
return circle?.label || member.value?.circle
})
const circleDescription = computed(() => {
const circle = getCircleByValue(member.value?.circle)
return circle?.description || ''
})
const contributionLabel = computed(() => {
const tier = getContributionTierByValue(member.value?.contributionTier)
return tier?.label || `$${member.value?.contributionTier}/month`
})
</script>

234
app/pages/series/index.vue Normal file
View file

@ -0,0 +1,234 @@
<template>
<div>
<!-- Hero Section -->
<div class="bg-gradient-to-br from-purple-600 via-blue-600 to-emerald-500 py-16">
<UContainer>
<div class="text-center">
<h1 class="text-4xl md:text-6xl font-bold text-white mb-6">
Event Series
</h1>
<p class="text-xl md:text-2xl text-purple-100 max-w-3xl mx-auto">
Discover our multi-event series designed to take you on a journey of learning and growth
</p>
</div>
</UContainer>
</div>
<!-- Series Grid -->
<div class="py-16 bg-gray-50">
<UContainer>
<div v-if="pending" class="text-center py-12">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-500 mx-auto mb-4"></div>
<p class="text-gray-600">Loading series...</p>
</div>
<div v-else-if="filteredSeries.length > 0" class="space-y-8">
<div
v-for="series in filteredSeries"
:key="series.id"
class="bg-white rounded-xl shadow-lg overflow-hidden hover:shadow-xl transition-shadow duration-300"
>
<!-- Series Header -->
<div class="p-6 border-b border-gray-200">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div class="flex-1">
<div class="flex items-center gap-3 mb-2">
<div :class="[
'inline-flex items-center px-3 py-1 rounded-full text-sm font-medium',
getSeriesTypeBadgeClass(series.type)
]">
{{ formatSeriesType(series.type) }}
</div>
<span :class="[
'inline-flex items-center px-2 py-1 rounded-full text-xs font-medium',
series.status === 'active' ? 'bg-green-100 text-green-700' :
series.status === 'upcoming' ? 'bg-blue-100 text-blue-700' :
'bg-gray-100 text-gray-700'
]">
{{ series.status }}
</span>
</div>
<h2 class="text-2xl font-bold text-gray-900 mb-2">{{ series.title }}</h2>
<p class="text-gray-600 leading-relaxed">{{ series.description }}</p>
</div>
<div class="text-center md:text-right">
<div class="text-3xl font-bold text-purple-600 mb-1">{{ series.eventCount }}</div>
<div class="text-sm text-gray-500">Events</div>
<div v-if="series.totalEvents" class="text-xs text-gray-400 mt-1">
of {{ series.totalEvents }} planned
</div>
</div>
</div>
</div>
<!-- Events List -->
<div class="divide-y divide-gray-100">
<div
v-for="event in series.events"
:key="event.id"
class="p-4 hover:bg-gray-50 transition-colors duration-200"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-4 flex-1">
<div class="w-10 h-10 bg-purple-100 text-purple-600 rounded-full flex items-center justify-center text-sm font-semibold">
{{ event.series?.position || '?' }}
</div>
<div class="flex-1">
<h3 class="font-medium text-gray-900 mb-1">{{ event.title }}</h3>
<div class="flex items-center gap-4 text-sm text-gray-500">
<div class="flex items-center gap-1">
<Icon name="heroicons:calendar-days" class="w-4 h-4" />
{{ formatEventDate(event.startDate) }}
</div>
<div class="flex items-center gap-1">
<Icon name="heroicons:clock" class="w-4 h-4" />
{{ formatEventTime(event.startDate) }}
</div>
<div v-if="event.registrations?.length" class="flex items-center gap-1">
<Icon name="heroicons:users" class="w-4 h-4" />
{{ event.registrations.length }} registered
</div>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<span :class="[
'inline-flex items-center px-2 py-1 rounded-full text-xs font-medium',
getEventStatusClass(event)
]">
{{ getEventStatus(event) }}
</span>
<NuxtLink
:to="`/events/${event.slug || event.id}`"
class="inline-flex items-center px-3 py-1 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700 transition-colors"
>
View Event
</NuxtLink>
</div>
</div>
</div>
</div>
<!-- Series Footer -->
<div v-if="series.startDate && series.endDate" class="px-6 py-4 bg-gray-50 border-t border-gray-200">
<div class="flex items-center justify-between text-sm text-gray-500">
<div class="flex items-center gap-1">
<Icon name="heroicons:calendar-days" class="w-4 h-4" />
Series runs from {{ formatDateRange(series.startDate, series.endDate) }}
</div>
<div v-if="series.totalRegistrations" class="flex items-center gap-1">
<Icon name="heroicons:users" class="w-4 h-4" />
{{ series.totalRegistrations }} total registrations
</div>
</div>
</div>
</div>
</div>
<div v-else class="text-center py-16">
<Icon name="heroicons:squares-2x2" class="w-16 h-16 text-gray-300 mx-auto mb-4" />
<h3 class="text-xl font-semibold text-gray-900 mb-2">No Event Series Available</h3>
<p class="text-gray-600 max-w-md mx-auto">
We're currently planning exciting event series. Check back soon for multi-event learning journeys!
</p>
</div>
</UContainer>
</div>
</div>
</template>
<script setup>
// SEO
useHead({
title: 'Event Series - Ghost Guild',
meta: [
{ name: 'description', content: 'Discover our multi-event series designed to take you on a journey of learning and growth in cooperative game development and community building.' }
]
})
// Fetch series data
const { data: seriesData, pending } = await useFetch('/api/series', {
query: { includeHidden: false }
})
// Filter for active and upcoming series only
const filteredSeries = computed(() => {
if (!seriesData.value) return []
return seriesData.value.filter(series =>
series.status === 'active' || series.status === 'upcoming'
)
})
// Helper functions
const formatSeriesType = (type) => {
const types = {
'workshop_series': 'Workshop Series',
'recurring_meetup': 'Recurring Meetup',
'multi_day': 'Multi-Day Event',
'course': 'Course',
'tournament': 'Tournament'
}
return types[type] || type
}
const getSeriesTypeBadgeClass = (type) => {
const classes = {
'workshop_series': 'bg-emerald-100 text-emerald-700',
'recurring_meetup': 'bg-blue-100 text-blue-700',
'multi_day': 'bg-purple-100 text-purple-700',
'course': 'bg-amber-100 text-amber-700',
'tournament': 'bg-red-100 text-red-700'
}
return classes[type] || 'bg-gray-100 text-gray-700'
}
const formatEventDate = (date) => {
return new Date(date).toLocaleDateString('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric'
})
}
const formatEventTime = (date) => {
return new Date(date).toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
})
}
const formatDateRange = (startDate, endDate) => {
const start = new Date(startDate)
const end = new Date(endDate)
const formatter = new Intl.DateTimeFormat('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric'
})
return `${formatter.format(start)} to ${formatter.format(end)}`
}
const getEventStatus = (event) => {
const now = new Date()
const startDate = new Date(event.startDate)
const endDate = new Date(event.endDate)
if (now < startDate) return 'Upcoming'
if (now >= startDate && now <= endDate) return 'Ongoing'
return 'Completed'
}
const getEventStatusClass = (event) => {
const status = getEventStatus(event)
const classes = {
'Upcoming': 'bg-blue-100 text-blue-700',
'Ongoing': 'bg-green-100 text-green-700',
'Completed': 'bg-gray-100 text-gray-700'
}
return classes[status] || 'bg-gray-100 text-gray-700'
}
</script>

View file

@ -0,0 +1,135 @@
<template>
<div>
<PageHeader
title="Edit Update"
subtitle="Make changes to your update"
theme="stone"
size="medium"
/>
<section class="py-12 px-4">
<UContainer class="px-4">
<!-- Loading State -->
<div
v-if="loading"
class="flex justify-center items-center py-20"
>
<div class="text-center">
<div
class="w-8 h-8 border-4 border-stone-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"
></div>
<p class="text-stone-400">Loading update...</p>
</div>
</div>
<!-- Edit Form -->
<div v-else-if="update" class="max-w-3xl">
<UpdateForm
:initial-data="update"
:submitting="submitting"
:error="error"
submit-label="Update"
@submit="handleSubmit"
@cancel="handleCancel"
/>
<!-- Success Message -->
<div
v-if="success"
class="mt-6 bg-green-500/10 border border-green-500/30 rounded-lg p-4"
>
<p class="text-green-300"> Update saved successfully!</p>
</div>
</div>
<!-- Not Found -->
<div v-else class="text-center py-20">
<p class="text-stone-400 mb-4">Update not found</p>
<UButton to="/updates" variant="outline" color="neutral">
Back to Updates
</UButton>
</div>
</UContainer>
</section>
</div>
</template>
<script setup>
const route = useRoute();
const { isAuthenticated, checkMemberStatus, memberData } = useAuth();
const update = ref(null);
const loading = ref(true);
const submitting = ref(false);
const error = ref(null);
const success = ref(false);
// Load update
const loadUpdate = async () => {
loading.value = true;
try {
const data = await $fetch(`/api/updates/${route.params.id}`);
// Check if user is the author
if (memberData.value && data.author._id !== memberData.value.id) {
error.value = "You can only edit your own updates";
update.value = null;
return;
}
update.value = data;
} catch (err) {
console.error("Failed to load update:", err);
error.value = err.data?.statusMessage || "Failed to load update";
} finally {
loading.value = false;
}
};
// Check authentication
onMounted(async () => {
if (!isAuthenticated.value) {
const authenticated = await checkMemberStatus();
if (!authenticated) {
await navigateTo("/login");
return;
}
}
await loadUpdate();
});
const handleSubmit = async (formData) => {
submitting.value = true;
error.value = null;
success.value = false;
try {
await $fetch(`/api/updates/${route.params.id}`, {
method: "PATCH",
body: formData,
});
success.value = true;
// Redirect to the update after a short delay
setTimeout(() => {
navigateTo(`/updates/${route.params.id}`);
}, 1000);
} catch (err) {
console.error("Failed to update:", err);
error.value =
err.data?.statusMessage || "Failed to save update. Please try again.";
} finally {
submitting.value = false;
}
};
const handleCancel = () => {
navigateTo(`/updates/${route.params.id}`);
};
useHead({
title: "Edit Update - Ghost Guild",
});
</script>

View file

@ -0,0 +1,153 @@
<template>
<div>
<PageHeader
title="Update"
subtitle="Member update"
theme="stone"
size="medium"
/>
<section class="py-12 px-4">
<UContainer class="px-4">
<!-- Loading State -->
<div v-if="loading" class="flex justify-center items-center py-20">
<div class="text-center">
<div
class="w-8 h-8 border-4 border-stone-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"
></div>
<p class="text-stone-400">Loading update...</p>
</div>
</div>
<!-- Update Content -->
<div v-else-if="update" class="max-w-3xl">
<UpdateCard
:update="update"
:show-preview="false"
@edit="handleEdit"
@delete="handleDelete"
/>
<!-- Comments Placeholder -->
<div
class="mt-8 p-8 border border-stone-700 rounded-lg bg-stone-800/30"
>
<h3 class="text-lg font-semibold text-stone-200 mb-4">Comments</h3>
<p class="text-stone-400 text-center py-8">Comments coming soon</p>
</div>
<!-- Back Button -->
<div class="mt-6">
<UButton
to="/updates"
variant="ghost"
color="neutral"
icon="i-lucide-arrow-left"
>
Back to Updates
</UButton>
</div>
</div>
<!-- Error State -->
<div v-else-if="error" class="text-center py-20">
<p class="text-stone-400 mb-4">{{ error }}</p>
<UButton to="/updates" variant="outline" color="neutral">
Back to Updates
</UButton>
</div>
</UContainer>
</section>
<!-- Delete Confirmation Modal -->
<UModal
v-model:open="showDeleteModal"
title="Delete Update?"
description="Are you sure you want to delete this update? This action cannot be undone."
>
<template #footer>
<div class="flex justify-end gap-3">
<UButton
variant="ghost"
color="neutral"
@click="showDeleteModal = false"
>
Cancel
</UButton>
<UButton color="red" :loading="deleting" @click="confirmDelete">
Delete
</UButton>
</div>
</template>
</UModal>
</div>
</template>
<script setup>
const route = useRoute();
const update = ref(null);
const loading = ref(true);
const error = ref(null);
const showDeleteModal = ref(false);
const deleting = ref(false);
// Load update
const loadUpdate = async () => {
loading.value = true;
error.value = null;
try {
const data = await $fetch(`/api/updates/${route.params.id}`);
update.value = data;
console.log("✅ Update loaded successfully:", data);
} catch (err) {
console.error("❌ Failed to load update:", err);
console.error("Error details:", {
status: err.statusCode,
message: err.data?.statusMessage,
data: err.data,
});
error.value =
err.data?.statusMessage || err.statusMessage || "Update not found";
} finally {
loading.value = false;
}
};
onMounted(() => {
loadUpdate();
});
const handleEdit = () => {
navigateTo(`/updates/${route.params.id}/edit`);
};
const handleDelete = () => {
showDeleteModal.value = true;
};
const confirmDelete = async () => {
deleting.value = true;
try {
await $fetch(`/api/updates/${route.params.id}`, {
method: "DELETE",
});
// Redirect to updates feed
await navigateTo("/updates");
} catch (err) {
console.error("Failed to delete update:", err);
alert("Failed to delete update. Please try again.");
deleting.value = false;
}
};
useHead({
title: computed(() =>
update.value
? `Update by ${update.value.author?.name} - Ghost Guild`
: "Update - Ghost Guild",
),
});
</script>

198
app/pages/updates/index.vue Normal file
View file

@ -0,0 +1,198 @@
<template>
<div>
<PageHeader
title="Community Updates"
subtitle="Share and discover what members are working on, learning, and thinking about"
theme="stone"
size="medium"
/>
<section class="py-12 px-4">
<UContainer class="px-4">
<!-- New Update Button -->
<div v-if="isAuthenticated" class="mb-8 flex justify-end">
<UButton to="/updates/new" icon="i-lucide-plus"> New Update </UButton>
</div>
<!-- Loading State -->
<div
v-if="pending && !updates.length"
class="flex justify-center items-center py-20"
>
<div class="text-center">
<div
class="w-8 h-8 border-4 border-stone-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"
></div>
<p class="text-stone-400">Loading updates...</p>
</div>
</div>
<!-- Updates Feed -->
<div v-else-if="updates.length" class="space-y-6">
<UpdateCard
v-for="update in updates"
:key="update._id"
:update="update"
:show-preview="true"
@edit="handleEdit"
@delete="handleDelete"
/>
<!-- Load More -->
<div v-if="hasMore" class="flex justify-center pt-4">
<UButton
variant="outline"
color="neutral"
:loading="loadingMore"
@click="loadMore"
>
Load More
</UButton>
</div>
</div>
<!-- Empty State -->
<div v-else class="text-center py-20">
<div class="w-16 h-16 mx-auto mb-4 opacity-50">
<svg
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
class="text-stone-600"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
</div>
<h3 class="text-lg font-medium text-stone-300 mb-2">
No updates yet
</h3>
<p class="text-stone-400 mb-6">
Be the first to share an update with the community!
</p>
<UButton v-if="isAuthenticated" to="/updates/new">
Post Your First Update
</UButton>
</div>
</UContainer>
</section>
<!-- Delete Confirmation Modal -->
<UModal
v-model:open="showDeleteModal"
title="Delete Update?"
description="Are you sure you want to delete this update? This action cannot be undone."
>
<template #footer>
<div class="flex justify-end gap-3">
<UButton
variant="ghost"
color="neutral"
@click="showDeleteModal = false"
>
Cancel
</UButton>
<UButton color="red" :loading="deleting" @click="confirmDelete">
Delete
</UButton>
</div>
</template>
</UModal>
</div>
</template>
<script setup>
const { isAuthenticated } = useAuth();
const updates = ref([]);
const pending = ref(false);
const loadingMore = ref(false);
const hasMore = ref(false);
const total = ref(0);
const showDeleteModal = ref(false);
const updateToDelete = ref(null);
const deleting = ref(false);
// Load initial updates
const loadUpdates = async () => {
pending.value = true;
try {
const response = await $fetch("/api/updates", {
params: { limit: 20, skip: 0 },
});
updates.value = response.updates;
total.value = response.total;
hasMore.value = response.hasMore;
} catch (error) {
console.error("Failed to load updates:", error);
} finally {
pending.value = false;
}
};
// Load more updates
const loadMore = async () => {
loadingMore.value = true;
try {
const response = await $fetch("/api/updates", {
params: { limit: 20, skip: updates.value.length },
});
updates.value.push(...response.updates);
hasMore.value = response.hasMore;
} catch (error) {
console.error("Failed to load more updates:", error);
} finally {
loadingMore.value = false;
}
};
// Handle edit
const handleEdit = (update) => {
navigateTo(`/updates/${update._id}/edit`);
};
// Handle delete
const handleDelete = (update) => {
updateToDelete.value = update;
showDeleteModal.value = true;
};
// Confirm delete
const confirmDelete = async () => {
if (!updateToDelete.value) return;
deleting.value = true;
try {
await $fetch(`/api/updates/${updateToDelete.value._id}`, {
method: "DELETE",
});
// Remove from list
updates.value = updates.value.filter(
(u) => u._id !== updateToDelete.value._id,
);
total.value--;
showDeleteModal.value = false;
updateToDelete.value = null;
} catch (error) {
console.error("Failed to delete update:", error);
alert("Failed to delete update. Please try again.");
} finally {
deleting.value = false;
}
};
onMounted(() => {
loadUpdates();
});
useHead({
title: "Community Updates - Ghost Guild",
});
</script>

84
app/pages/updates/new.vue Normal file
View file

@ -0,0 +1,84 @@
<template>
<div>
<PageHeader
title="New Update"
subtitle="Share what you're working on, learning, or thinking about"
theme="stone"
size="medium"
/>
<section class="py-12 px-4">
<UContainer class="px-4">
<div class="max-w-3xl">
<UpdateForm
:submitting="submitting"
:error="error"
submit-label="Post Update"
@submit="handleSubmit"
@cancel="handleCancel"
/>
<!-- Success Message -->
<div
v-if="success"
class="mt-6 bg-green-500/10 border border-green-500/30 rounded-lg p-4"
>
<p class="text-green-300"> Update posted successfully!</p>
</div>
</div>
</UContainer>
</section>
</div>
</template>
<script setup>
const { isAuthenticated, checkMemberStatus } = useAuth();
const submitting = ref(false);
const error = ref(null);
const success = ref(false);
// Check authentication
onMounted(async () => {
if (!isAuthenticated.value) {
const authenticated = await checkMemberStatus();
if (!authenticated) {
await navigateTo("/login");
}
}
});
const handleSubmit = async (formData) => {
submitting.value = true;
error.value = null;
success.value = false;
try {
const update = await $fetch("/api/updates", {
method: "POST",
body: formData,
});
success.value = true;
// Redirect to the update after a short delay
setTimeout(() => {
navigateTo(`/updates/${update._id}`);
}, 1000);
} catch (err) {
console.error("Failed to create update:", err);
error.value =
err.data?.statusMessage || "Failed to post update. Please try again.";
} finally {
submitting.value = false;
}
};
const handleCancel = () => {
navigateTo("/updates");
};
useHead({
title: "New Update - Ghost Guild",
});
</script>

View file

@ -0,0 +1,193 @@
<template>
<div>
<PageHeader
:title="user?.name ? `${user.name}'s Updates` : 'User Updates'"
:subtitle="user?.name ? `All updates from ${user.name}` : 'Loading...'"
theme="stone"
size="medium"
/>
<section class="py-12 px-4">
<UContainer class="px-4">
<!-- Loading State -->
<div
v-if="pending && !updates.length"
class="flex justify-center items-center py-20"
>
<div class="text-center">
<div
class="w-8 h-8 border-4 border-stone-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"
></div>
<p class="text-stone-400">Loading updates...</p>
</div>
</div>
<!-- Updates Feed -->
<div v-else-if="updates.length" class="space-y-6">
<UpdateCard
v-for="update in updates"
:key="update._id"
:update="update"
:show-preview="true"
@edit="handleEdit"
@delete="handleDelete"
/>
<!-- Load More -->
<div v-if="hasMore" class="flex justify-center pt-4">
<UButton
variant="outline"
color="neutral"
:loading="loadingMore"
@click="loadMore"
>
Load More
</UButton>
</div>
</div>
<!-- Empty State -->
<div v-else class="text-center py-20">
<div class="w-16 h-16 mx-auto mb-4 opacity-50">
<svg
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
class="text-stone-600"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
</div>
<h3 class="text-lg font-medium text-stone-300 mb-2">
No updates yet
</h3>
<p class="text-stone-400">
{{ user?.name || "This user" }} hasn't posted any updates.
</p>
</div>
</UContainer>
</section>
<!-- Delete Confirmation Modal -->
<UModal
v-model:open="showDeleteModal"
title="Delete Update?"
description="Are you sure you want to delete this update? This action cannot be undone."
>
<template #footer>
<div class="flex justify-end gap-3">
<UButton
variant="ghost"
color="neutral"
@click="showDeleteModal = false"
>
Cancel
</UButton>
<UButton color="red" :loading="deleting" @click="confirmDelete">
Delete
</UButton>
</div>
</template>
</UModal>
</div>
</template>
<script setup>
const route = useRoute();
const userId = computed(() => route.params.id);
const updates = ref([]);
const user = ref(null);
const pending = ref(false);
const loadingMore = ref(false);
const hasMore = ref(false);
const total = ref(0);
const showDeleteModal = ref(false);
const updateToDelete = ref(null);
const deleting = ref(false);
// Load user updates
const loadUpdates = async () => {
pending.value = true;
try {
const response = await $fetch(`/api/updates/user/${userId.value}`, {
params: { limit: 20, skip: 0 },
});
updates.value = response.updates;
user.value = response.user;
total.value = response.total;
hasMore.value = response.hasMore;
} catch (error) {
console.error("Failed to load updates:", error);
} finally {
pending.value = false;
}
};
// Load more updates
const loadMore = async () => {
loadingMore.value = true;
try {
const response = await $fetch(`/api/updates/user/${userId.value}`, {
params: { limit: 20, skip: updates.value.length },
});
updates.value.push(...response.updates);
hasMore.value = response.hasMore;
} catch (error) {
console.error("Failed to load more updates:", error);
} finally {
loadingMore.value = false;
}
};
// Handle edit
const handleEdit = (update) => {
navigateTo(`/updates/${update._id}/edit`);
};
// Handle delete
const handleDelete = (update) => {
updateToDelete.value = update;
showDeleteModal.value = true;
};
// Confirm delete
const confirmDelete = async () => {
if (!updateToDelete.value) return;
deleting.value = true;
try {
await $fetch(`/api/updates/${updateToDelete.value._id}`, {
method: "DELETE",
});
// Remove from list
updates.value = updates.value.filter(
(u) => u._id !== updateToDelete.value._id,
);
total.value--;
showDeleteModal.value = false;
updateToDelete.value = null;
} catch (error) {
console.error("Failed to delete update:", error);
alert("Failed to delete update. Please try again.");
} finally {
deleting.value = false;
}
};
onMounted(() => {
loadUpdates();
});
useHead({
title: computed(() => user.value?.name ? `${user.value.name}'s Updates - Ghost Guild` : 'User Updates - Ghost Guild'),
});
</script>

View file

@ -0,0 +1,20 @@
export default defineNuxtPlugin(async () => {
const { memberData, checkMemberStatus } = useAuth()
console.log('🚀 Auth init plugin running on CLIENT')
// Only initialize if we don't already have member data
if (!memberData.value) {
console.log(' - No member data, checking auth status...')
const isAuthenticated = await checkMemberStatus()
if (isAuthenticated) {
console.log(' - ✅ Authentication successful')
} else {
console.log(' - ❌ No valid authentication')
}
} else {
console.log(' - ✅ Member data already exists:', memberData.value.email)
}
})

42
debug-token.js Normal file
View file

@ -0,0 +1,42 @@
// Debug token encoding
const originalToken = 'aG_Eu%lqXCIJdWb2fUx52P_*-9GzaUHAVXvRjF43#sZw_FEeV9q7gl$pe$1EPRNs'
// Manually fix the %lq part - it should be a literal character, not URL encoded
const correctedToken = originalToken.replace('%lq', 'lq')
console.log('Original token:', originalToken)
console.log('Corrected token:', correctedToken)
console.log('Are they different?', originalToken !== correctedToken)
async function testBoth() {
console.log('\n=== Testing Original Token ===')
try {
const response1 = await fetch('https://api.helcim.com/v2/connection-test', {
headers: {
'accept': 'application/json',
'api-token': originalToken
}
})
console.log('Original token status:', response1.status)
const data1 = await response1.text()
console.log('Original token response:', data1)
} catch (error) {
console.error('Original token error:', error.message)
}
console.log('\n=== Testing Corrected Token ===')
try {
const response2 = await fetch('https://api.helcim.com/v2/connection-test', {
headers: {
'accept': 'application/json',
'api-token': correctedToken
}
})
console.log('Corrected token status:', response2.status)
const data2 = await response2.text()
console.log('Corrected token response:', data2)
} catch (error) {
console.error('Corrected token error:', error.message)
}
}
testBoth()

View file

@ -3,6 +3,11 @@ export default defineNuxtConfig({
compatibilityDate: "2025-07-15", compatibilityDate: "2025-07-15",
devtools: { enabled: true }, devtools: { enabled: true },
modules: ["@nuxt/eslint", "@nuxt/ui", "@nuxtjs/plausible"], modules: ["@nuxt/eslint", "@nuxt/ui", "@nuxtjs/plausible"],
ui: {
theme: {
colors: ['primary', 'neutral', 'ghost', 'whisper', 'sparkle']
}
},
build: { build: {
transpile: ['vue-cal'] transpile: ['vue-cal']
}, },
@ -16,6 +21,9 @@ export default defineNuxtConfig({
jwtSecret: process.env.JWT_SECRET || 'dev-secret-change-in-production', jwtSecret: process.env.JWT_SECRET || 'dev-secret-change-in-production',
resendApiKey: process.env.RESEND_API_KEY || '', resendApiKey: process.env.RESEND_API_KEY || '',
helcimApiToken: process.env.HELCIM_API_TOKEN || '', helcimApiToken: process.env.HELCIM_API_TOKEN || '',
slackBotToken: process.env.SLACK_BOT_TOKEN || '',
slackSigningSecret: process.env.SLACK_SIGNING_SECRET || '',
slackVettingChannelId: process.env.SLACK_VETTING_CHANNEL_ID || '',
// Public keys (available on client-side) // Public keys (available on client-side)
public: { public: {

1102
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -14,9 +14,11 @@
"@headlessui/vue": "^1.7.23", "@headlessui/vue": "^1.7.23",
"@heroicons/vue": "^2.2.0", "@heroicons/vue": "^2.2.0",
"@nuxt/eslint": "^1.9.0", "@nuxt/eslint": "^1.9.0",
"@nuxt/ui": "^3.3.2", "@nuxt/ui": "^4.0.0",
"@nuxtjs/plausible": "^1.2.0", "@nuxtjs/plausible": "^1.2.0",
"@slack/web-api": "^7.10.0",
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
"chrono-node": "^2.8.4",
"cloudinary": "^2.7.0", "cloudinary": "^2.7.0",
"eslint": "^9.34.0", "eslint": "^9.34.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="135px" height="129px" viewBox="0 0 135 129" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>Ghost-Disbelieving Copy</title>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Ghost-Disbelieving-Copy">
<g id="Bod" fill="#E58FFC">
<polygon id="Ectoball" points="59.75 0 59.75 1.79362973 50.7916667 1.79362973 50.7916667 3.58526875 43.6269907 3.58526875 43.6269907 7.16854678 34.6686574 7.16854678 34.6686574 10.7518248 27.5 10.7518248 27.5 16.1267419 22.125 16.1267419 22.125 21.5016589 16.7519907 21.5016589 16.7519907 28.668215 13.1666667 28.668215 13.1666667 37.6264101 9.58333333 37.6264101 9.58333333 44.7909754 7.79365741 44.7909754 7.79365741 53.7491705 6 53.7491705 6 75.2508295 7.79365741 75.2508295 7.79365741 84.2090246 9.58333333 84.2090246 9.58333333 91.3755806 13.1666667 91.3755806 13.1666667 100.333776 16.7519907 100.333776 16.7519907 107.498341 22.125 107.498341 22.125 112.873258 27.5 112.873258 27.5 118.250166 34.6686574 118.250166 34.6686574 121.831453 43.6269907 121.831453 43.6269907 125.414731 50.7916667 125.414731 50.7916667 127.208361 59.75 127.208361 59.75 129 81.25 129 81.25 127.208361 90.2083333 127.208361 90.2083333 125.414731 97.3769907 125.414731 97.3769907 121.831453 106.335324 121.831453 106.335324 118.250166 113.5 118.250166 113.5 112.873258 118.875 112.873258 118.875 107.498341 124.251991 107.498341 124.251991 100.333776 127.833333 100.333776 127.833333 91.3755806 131.416667 91.3755806 131.416667 84.2090246 133.210324 84.2090246 133.210324 75.2508295 135 75.2508295 135 53.7491705 133.210324 53.7491705 133.210324 44.7909754 131.416667 44.7909754 131.416667 37.6264101 127.833333 37.6264101 127.833333 28.668215 124.251991 28.668215 124.251991 21.5016589 118.875 21.5016589 118.875 16.1267419 113.5 16.1267419 113.5 10.7518248 106.335324 10.7518248 106.335324 7.16854678 97.3769907 7.16854678 97.3769907 3.58526875 90.2083333 3.58526875 90.2083333 1.79362973 81.25 1.79362973 81.25 0"></polygon>
<polygon id="Flagellum" points="1.35555319 82 1.35555319 83.3080672 0 83.3080672 0 98.9990253 1.35555319 98.9990253 1.35555319 100.309042 9.50101161 100.309042 9.50101161 104.231294 8.1434352 104.231294 8.1434352 106.847428 1.35555319 106.847428 1.35555319 108.153546 0 108.153546 0 114.693882 1.35555319 114.693882 1.35555319 116 10.8545416 116 10.8545416 114.693882 13.5696944 114.693882 13.5696944 112.079697 16.2848472 112.079697 16.2848472 109.463563 17.6444468 109.463563 17.6444468 104.231294 19 104.231294 19 83.3080672 17.6444468 83.3080672 17.6444468 82"></polygon>
</g>
<g id="Disbelieving-Face" transform="translate(39, 39)" fill="#000000">
<path d="M4.29107692,12.5 L4.29107692,13.75 L4.90297436,13.75 L4.90297436,15.0019873 L5.51682051,15.0019873 L5.51682051,15.6259936 L6.7425641,15.6259936 L6.7425641,16.2519873 L12.2574359,16.2519873 L12.2574359,15.6259936 L13.4831795,15.6259936 L13.4831795,15.0019873 L14.0950769,15.0019873 L14.0950769,13.75 L14.7089231,13.75 L14.7089231,12.5 L15.3208205,12.5 L15.3208205,7.5 L14.7089231,7.5 L14.7089231,6.24801272 L14.0950769,6.24801272 L14.0950769,5 L13.4831795,5 L13.4831795,4.37400636 L12.2574359,4.37400636 L12.2574359,3.75 L6.7425641,3.75 L6.7425641,4.37400636 L5.51682051,4.37400636 L5.51682051,5 L4.90297436,5 L4.90297436,6.24801272 L4.29107692,6.24801272 L4.29107692,7.5 L3.67723077,7.5 L3.67723077,12.5 L4.29107692,12.5 Z M0,13.1240064 L0,6.87400636 L0.611897436,6.87400636 L0.611897436,5 L1.22574359,5 L1.22574359,3.75 L1.83764103,3.75 L1.83764103,3.12400636 L2.45148718,3.12400636 L2.45148718,2.5 L3.06338462,2.5 L3.06338462,1.87400636 L3.67723077,1.87400636 L3.67723077,1.25 L4.29107692,1.25 L4.29107692,0.624006359 L6.12871795,0.624006359 L6.12871795,0 L12.8693333,0 L12.8693333,0.624006359 L14.7089231,0.624006359 L14.7089231,1.25 L15.3208205,1.25 L15.3208205,1.87400636 L15.9346667,1.87400636 L15.9346667,2.5 L16.5465641,2.5 L16.5465641,3.12400636 L17.1604103,3.12400636 L17.1604103,3.75 L17.7742564,3.75 L17.7742564,5 L18.3861538,5 L18.3861538,6.87400636 L19,6.87400636 L19,13.1240064 L18.3861538,13.1240064 L18.3861538,15.0019873 L17.7742564,15.0019873 L17.7742564,16.2519873 L17.1604103,16.2519873 L17.1604103,16.8759936 L16.5465641,16.8759936 L16.5465641,17.5019873 L15.9346667,17.5019873 L15.9346667,18.1259936 L15.3208205,18.1259936 L15.3208205,18.7519873 L14.7089231,18.7519873 L14.7089231,19.3759936 L12.8693333,19.3759936 L12.8693333,20 L6.12871795,20 L6.12871795,19.3759936 L4.29107692,19.3759936 L4.29107692,18.7519873 L3.67723077,18.7519873 L3.67723077,18.1259936 L3.06338462,18.1259936 L3.06338462,17.5019873 L2.45148718,17.5019873 L2.45148718,16.8759936 L1.83764103,16.8759936 L1.83764103,16.2519873 L1.22574359,16.2519873 L1.22574359,15.0019873 L0.611897436,15.0019873 L0.611897436,13.1240064 L0,13.1240064 Z" id="Eye---Left"></path>
<path d="M51.6541785,37.150159 L53.5444805,37.150159 L53.5444805,35.9049133 L54.8053501,35.9049133 L54.8053501,34.0380347 L55.4367872,34.0380347 L55.4367872,31.5455636 L56.0662197,31.5455636 L56.0662197,23.4524567 L55.4367872,23.4524567 L55.4367872,20.9619653 L54.8053501,20.9619653 L54.8053501,19.0950867 L53.5444805,19.0950867 L53.5444805,17.849841 L51.6541785,17.849841 L51.6541785,17.2282081 L45.3478261,17.2282081 L45.3478261,17.849841 L43.457524,17.849841 L43.457524,19.0950867 L42.1966545,19.0950867 L42.1966545,20.9619653 L41.567222,20.9619653 L41.567222,23.4524567 L40.9357849,23.4524567 L40.9357849,31.5455636 L41.567222,31.5455636 L41.567222,34.0380347 L42.1966545,34.0380347 L42.1966545,35.9049133 L43.457524,35.9049133 L43.457524,37.150159 L45.3478261,37.150159 L45.3478261,37.7717919 L51.6541785,37.7717919 L51.6541785,37.150159 Z M52.283611,11 L52.283611,11.621633 L54.8053501,11.621633 L54.8053501,12.8668786 L57.3270892,12.8668786 L57.3270892,14.114104 L59.2173913,14.114104 L59.2173913,15.3593497 L60.4782609,15.3593497 L60.4782609,17.2282081 L61.7391304,17.2282081 L61.7391304,19.7167197 L62.3705675,19.7167197 L62.3705675,22.8308237 L63,22.8308237 L63,32.1671966 L62.3705675,32.1671966 L62.3705675,35.2832803 L61.7391304,35.2832803 L61.7391304,37.7717919 L60.4782609,37.7717919 L60.4782609,39.6406503 L59.2173913,39.6406503 L59.2173913,40.8878757 L57.3270892,40.8878757 L57.3270892,42.1331214 L54.8053501,42.1331214 L54.8053501,43.378367 L52.283611,43.378367 L52.283611,44 L44.7183936,44 L44.7183936,43.378367 L42.1966545,43.378367 L42.1966545,42.1331214 L39.6749153,42.1331214 L39.6749153,40.8878757 L37.7826087,40.8878757 L37.7826087,39.6406503 L36.5217391,39.6406503 L36.5217391,37.7717919 L35.2608696,37.7717919 L35.2608696,35.2832803 L34.6314371,35.2832803 L34.6314371,32.1671966 L34,32.1671966 L34,22.8308237 L34.6314371,22.8308237 L34.6314371,19.7167197 L35.2608696,19.7167197 L35.2608696,17.2282081 L36.5217391,17.2282081 L36.5217391,15.3593497 L37.7826087,15.3593497 L37.7826087,14.114104 L39.6749153,14.114104 L39.6749153,12.8668786 L42.1966545,12.8668786 L42.1966545,11.621633 L44.7183936,11.621633 L44.7183936,11 L52.283611,11 Z" id="Mouth"></path>
<path d="M76.2910769,12.5 L76.2910769,13.75 L76.9029744,13.75 L76.9029744,15.0019873 L77.5168205,15.0019873 L77.5168205,15.6259936 L78.7425641,15.6259936 L78.7425641,16.2519873 L84.2574359,16.2519873 L84.2574359,15.6259936 L85.4831795,15.6259936 L85.4831795,15.0019873 L86.0950769,15.0019873 L86.0950769,13.75 L86.7089231,13.75 L86.7089231,12.5 L87.3208205,12.5 L87.3208205,7.5 L86.7089231,7.5 L86.7089231,6.24801272 L86.0950769,6.24801272 L86.0950769,5 L85.4831795,5 L85.4831795,4.37400636 L84.2574359,4.37400636 L84.2574359,3.75 L78.7425641,3.75 L78.7425641,4.37400636 L77.5168205,4.37400636 L77.5168205,5 L76.9029744,5 L76.9029744,6.24801272 L76.2910769,6.24801272 L76.2910769,7.5 L75.6772308,7.5 L75.6772308,12.5 L76.2910769,12.5 Z M72,13.1240064 L72,6.87400636 L72.6118974,6.87400636 L72.6118974,5 L73.2257436,5 L73.2257436,3.75 L73.837641,3.75 L73.837641,3.12400636 L74.4514872,3.12400636 L74.4514872,2.5 L75.0633846,2.5 L75.0633846,1.87400636 L75.6772308,1.87400636 L75.6772308,1.25 L76.2910769,1.25 L76.2910769,0.624006359 L78.1287179,0.624006359 L78.1287179,0 L84.8693333,0 L84.8693333,0.624006359 L86.7089231,0.624006359 L86.7089231,1.25 L87.3208205,1.25 L87.3208205,1.87400636 L87.9346667,1.87400636 L87.9346667,2.5 L88.5465641,2.5 L88.5465641,3.12400636 L89.1604103,3.12400636 L89.1604103,3.75 L89.7742564,3.75 L89.7742564,5 L90.3861538,5 L90.3861538,6.87400636 L91,6.87400636 L91,13.1240064 L90.3861538,13.1240064 L90.3861538,15.0019873 L89.7742564,15.0019873 L89.7742564,16.2519873 L89.1604103,16.2519873 L89.1604103,16.8759936 L88.5465641,16.8759936 L88.5465641,17.5019873 L87.9346667,17.5019873 L87.9346667,18.1259936 L87.3208205,18.1259936 L87.3208205,18.7519873 L86.7089231,18.7519873 L86.7089231,19.3759936 L84.8693333,19.3759936 L84.8693333,20 L78.1287179,20 L78.1287179,19.3759936 L76.2910769,19.3759936 L76.2910769,18.7519873 L75.6772308,18.7519873 L75.6772308,18.1259936 L75.0633846,18.1259936 L75.0633846,17.5019873 L74.4514872,17.5019873 L74.4514872,16.8759936 L73.837641,16.8759936 L73.837641,16.2519873 L73.2257436,16.2519873 L73.2257436,15.0019873 L72.6118974,15.0019873 L72.6118974,13.1240064 L72,13.1240064 Z" id="Eye---Right"></path>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3 KiB

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="135px" height="129px" viewBox="0 0 135 129" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>Ghost-Double-Take Copy</title>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Ghost-Double-Take--Copy">
<g id="Bod" fill="#FF8C49">
<polygon id="Ectoball" points="59.75 0 59.75 1.79362973 50.7916667 1.79362973 50.7916667 3.58526875 43.6269907 3.58526875 43.6269907 7.16854678 34.6686574 7.16854678 34.6686574 10.7518248 27.5 10.7518248 27.5 16.1267419 22.125 16.1267419 22.125 21.5016589 16.7519907 21.5016589 16.7519907 28.668215 13.1666667 28.668215 13.1666667 37.6264101 9.58333333 37.6264101 9.58333333 44.7909754 7.79365741 44.7909754 7.79365741 53.7491705 6 53.7491705 6 75.2508295 7.79365741 75.2508295 7.79365741 84.2090246 9.58333333 84.2090246 9.58333333 91.3755806 13.1666667 91.3755806 13.1666667 100.333776 16.7519907 100.333776 16.7519907 107.498341 22.125 107.498341 22.125 112.873258 27.5 112.873258 27.5 118.250166 34.6686574 118.250166 34.6686574 121.831453 43.6269907 121.831453 43.6269907 125.414731 50.7916667 125.414731 50.7916667 127.208361 59.75 127.208361 59.75 129 81.25 129 81.25 127.208361 90.2083333 127.208361 90.2083333 125.414731 97.3769907 125.414731 97.3769907 121.831453 106.335324 121.831453 106.335324 118.250166 113.5 118.250166 113.5 112.873258 118.875 112.873258 118.875 107.498341 124.251991 107.498341 124.251991 100.333776 127.833333 100.333776 127.833333 91.3755806 131.416667 91.3755806 131.416667 84.2090246 133.210324 84.2090246 133.210324 75.2508295 135 75.2508295 135 53.7491705 133.210324 53.7491705 133.210324 44.7909754 131.416667 44.7909754 131.416667 37.6264101 127.833333 37.6264101 127.833333 28.668215 124.251991 28.668215 124.251991 21.5016589 118.875 21.5016589 118.875 16.1267419 113.5 16.1267419 113.5 10.7518248 106.335324 10.7518248 106.335324 7.16854678 97.3769907 7.16854678 97.3769907 3.58526875 90.2083333 3.58526875 90.2083333 1.79362973 81.25 1.79362973 81.25 0"></polygon>
<polygon id="Flagellum" points="1.35555319 82 1.35555319 83.3080672 0 83.3080672 0 98.9990253 1.35555319 98.9990253 1.35555319 100.309042 9.50101161 100.309042 9.50101161 104.231294 8.1434352 104.231294 8.1434352 106.847428 1.35555319 106.847428 1.35555319 108.153546 0 108.153546 0 114.693882 1.35555319 114.693882 1.35555319 116 10.8545416 116 10.8545416 114.693882 13.5696944 114.693882 13.5696944 112.079697 16.2848472 112.079697 16.2848472 109.463563 17.6444468 109.463563 17.6444468 104.231294 19 104.231294 19 83.3080672 17.6444468 83.3080672 17.6444468 82"></polygon>
</g>
<g id="Double-Take-Face" transform="translate(30, 50)" fill="#000000">
<path d="M17.6541785,26.150159 L19.5444805,26.150159 L19.5444805,24.9049133 L20.8053501,24.9049133 L20.8053501,23.0380347 L21.4367872,23.0380347 L21.4367872,20.5455636 L22.0662197,20.5455636 L22.0662197,12.4524567 L21.4367872,12.4524567 L21.4367872,9.96196532 L20.8053501,9.96196532 L20.8053501,8.09508669 L19.5444805,8.09508669 L19.5444805,6.84984102 L17.6541785,6.84984102 L17.6541785,6.22820805 L11.3478261,6.22820805 L11.3478261,6.84984102 L9.45752402,6.84984102 L9.45752402,8.09508669 L8.19665445,8.09508669 L8.19665445,9.96196532 L7.56722195,9.96196532 L7.56722195,12.4524567 L6.93578489,12.4524567 L6.93578489,20.5455636 L7.56722195,20.5455636 L7.56722195,23.0380347 L8.19665445,23.0380347 L8.19665445,24.9049133 L9.45752402,24.9049133 L9.45752402,26.150159 L11.3478261,26.150159 L11.3478261,26.7717919 L17.6541785,26.7717919 L17.6541785,26.150159 Z M18.283611,0 L18.283611,0.621632971 L20.8053501,0.621632971 L20.8053501,1.86687864 L23.3270892,1.86687864 L23.3270892,3.11410403 L25.2173913,3.11410403 L25.2173913,4.35934969 L26.4782609,4.35934969 L26.4782609,6.22820805 L27.7391304,6.22820805 L27.7391304,8.71671966 L28.3705675,8.71671966 L28.3705675,11.8308237 L29,11.8308237 L29,21.1671966 L28.3705675,21.1671966 L28.3705675,24.2832803 L27.7391304,24.2832803 L27.7391304,26.7717919 L26.4782609,26.7717919 L26.4782609,28.6406503 L25.2173913,28.6406503 L25.2173913,29.8878757 L23.3270892,29.8878757 L23.3270892,31.1331214 L20.8053501,31.1331214 L20.8053501,32.378367 L18.283611,32.378367 L18.283611,33 L10.7183936,33 L10.7183936,32.378367 L8.19665445,32.378367 L8.19665445,31.1331214 L5.67491532,31.1331214 L5.67491532,29.8878757 L3.7826087,29.8878757 L3.7826087,28.6406503 L2.52173913,28.6406503 L2.52173913,26.7717919 L1.26086957,26.7717919 L1.26086957,24.2832803 L0.631437064,24.2832803 L0.631437064,21.1671966 L0,21.1671966 L0,11.8308237 L0.631437064,11.8308237 L0.631437064,8.71671966 L1.26086957,8.71671966 L1.26086957,6.22820805 L2.52173913,6.22820805 L2.52173913,4.35934969 L3.7826087,4.35934969 L3.7826087,3.11410403 L5.67491532,3.11410403 L5.67491532,1.86687864 L8.19665445,1.86687864 L8.19665445,0.621632971 L10.7183936,0.621632971 L10.7183936,0 L18.283611,0 Z" id="Eye---Left"></path>
<polygon id="Mouth" points="64.3735661 26 64.3735661 26.5988557 65 26.5988557 65 31.3992371 64.3735661 31.3992371 64.3735661 32 33.6284289 32 33.6284289 31.3992371 33 31.3992371 33 26.5988557 33.6284289 26.5988557 33.6284289 26"></polygon>
<path d="M85.6541785,26.150159 L87.5444805,26.150159 L87.5444805,24.9049133 L88.8053501,24.9049133 L88.8053501,23.0380347 L89.4367872,23.0380347 L89.4367872,20.5455636 L90.0662197,20.5455636 L90.0662197,12.4524567 L89.4367872,12.4524567 L89.4367872,9.96196532 L88.8053501,9.96196532 L88.8053501,8.09508669 L87.5444805,8.09508669 L87.5444805,6.84984102 L85.6541785,6.84984102 L85.6541785,6.22820805 L79.3478261,6.22820805 L79.3478261,6.84984102 L77.457524,6.84984102 L77.457524,8.09508669 L76.1966545,8.09508669 L76.1966545,9.96196532 L75.567222,9.96196532 L75.567222,12.4524567 L74.9357849,12.4524567 L74.9357849,20.5455636 L75.567222,20.5455636 L75.567222,23.0380347 L76.1966545,23.0380347 L76.1966545,24.9049133 L77.457524,24.9049133 L77.457524,26.150159 L79.3478261,26.150159 L79.3478261,26.7717919 L85.6541785,26.7717919 L85.6541785,26.150159 Z M86.283611,0 L86.283611,0.621632971 L88.8053501,0.621632971 L88.8053501,1.86687864 L91.3270892,1.86687864 L91.3270892,3.11410403 L93.2173913,3.11410403 L93.2173913,4.35934969 L94.4782609,4.35934969 L94.4782609,6.22820805 L95.7391304,6.22820805 L95.7391304,8.71671966 L96.3705675,8.71671966 L96.3705675,11.8308237 L97,11.8308237 L97,21.1671966 L96.3705675,21.1671966 L96.3705675,24.2832803 L95.7391304,24.2832803 L95.7391304,26.7717919 L94.4782609,26.7717919 L94.4782609,28.6406503 L93.2173913,28.6406503 L93.2173913,29.8878757 L91.3270892,29.8878757 L91.3270892,31.1331214 L88.8053501,31.1331214 L88.8053501,32.378367 L86.283611,32.378367 L86.283611,33 L78.7183936,33 L78.7183936,32.378367 L76.1966545,32.378367 L76.1966545,31.1331214 L73.6749153,31.1331214 L73.6749153,29.8878757 L71.7826087,29.8878757 L71.7826087,28.6406503 L70.5217391,28.6406503 L70.5217391,26.7717919 L69.2608696,26.7717919 L69.2608696,24.2832803 L68.6314371,24.2832803 L68.6314371,21.1671966 L68,21.1671966 L68,11.8308237 L68.6314371,11.8308237 L68.6314371,8.71671966 L69.2608696,8.71671966 L69.2608696,6.22820805 L70.5217391,6.22820805 L70.5217391,4.35934969 L71.7826087,4.35934969 L71.7826087,3.11410403 L73.6749153,3.11410403 L73.6749153,1.86687864 L76.1966545,1.86687864 L76.1966545,0.621632971 L78.7183936,0.621632971 L78.7183936,0 L86.283611,0 Z" id="Eye---Right"></path>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="136px" height="129px" viewBox="0 0 136 129" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>Ghost-Exasperated Copy</title>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Ghost-Exasperated-Copy" transform="translate(0.6667, 0)">
<g id="Bod" fill="#FFDE17">
<polygon id="Ectoball" points="59.75 0 59.75 1.79362973 50.7916667 1.79362973 50.7916667 3.58526875 43.6269907 3.58526875 43.6269907 7.16854678 34.6686574 7.16854678 34.6686574 10.7518248 27.5 10.7518248 27.5 16.1267419 22.125 16.1267419 22.125 21.5016589 16.7519907 21.5016589 16.7519907 28.668215 13.1666667 28.668215 13.1666667 37.6264101 9.58333333 37.6264101 9.58333333 44.7909754 7.79365741 44.7909754 7.79365741 53.7491705 6 53.7491705 6 75.2508295 7.79365741 75.2508295 7.79365741 84.2090246 9.58333333 84.2090246 9.58333333 91.3755806 13.1666667 91.3755806 13.1666667 100.333776 16.7519907 100.333776 16.7519907 107.498341 22.125 107.498341 22.125 112.873258 27.5 112.873258 27.5 118.250166 34.6686574 118.250166 34.6686574 121.831453 43.6269907 121.831453 43.6269907 125.414731 50.7916667 125.414731 50.7916667 127.208361 59.75 127.208361 59.75 129 81.25 129 81.25 127.208361 90.2083333 127.208361 90.2083333 125.414731 97.3769907 125.414731 97.3769907 121.831453 106.335324 121.831453 106.335324 118.250166 113.5 118.250166 113.5 112.873258 118.875 112.873258 118.875 107.498341 124.251991 107.498341 124.251991 100.333776 127.833333 100.333776 127.833333 91.3755806 131.416667 91.3755806 131.416667 84.2090246 133.210324 84.2090246 133.210324 75.2508295 135 75.2508295 135 53.7491705 133.210324 53.7491705 133.210324 44.7909754 131.416667 44.7909754 131.416667 37.6264101 127.833333 37.6264101 127.833333 28.668215 124.251991 28.668215 124.251991 21.5016589 118.875 21.5016589 118.875 16.1267419 113.5 16.1267419 113.5 10.7518248 106.335324 10.7518248 106.335324 7.16854678 97.3769907 7.16854678 97.3769907 3.58526875 90.2083333 3.58526875 90.2083333 1.79362973 81.25 1.79362973 81.25 0"></polygon>
<polygon id="Flagellum" points="1.35555319 82 1.35555319 83.3080672 0 83.3080672 0 98.9990253 1.35555319 98.9990253 1.35555319 100.309042 9.50101161 100.309042 9.50101161 104.231294 8.1434352 104.231294 8.1434352 106.847428 1.35555319 106.847428 1.35555319 108.153546 0 108.153546 0 114.693882 1.35555319 114.693882 1.35555319 116 10.8545416 116 10.8545416 114.693882 13.5696944 114.693882 13.5696944 112.079697 16.2848472 112.079697 16.2848472 109.463563 17.6444468 109.463563 17.6444468 104.231294 19 104.231294 19 83.3080672 17.6444468 83.3080672 17.6444468 82"></polygon>
</g>
<g id="Exasperated-Face" transform="translate(30, 63)" fill="#000000">
<polygon id="Eye---Left" points="19.3948738 0 19.3948738 0.599046105 20 0.599046105 20 5.3990461 19.3948738 5.3990461 19.3948738 6 0.605126229 6 0.605126229 5.3990461 0 5.3990461 0 0.599046105 0.605126229 0.599046105 0.605126229 0"></polygon>
<polygon id="Mouth" points="64.3735661 13 64.3735661 13.5988557 65 13.5988557 65 18.3992371 64.3735661 18.3992371 64.3735661 19 33.6284289 19 33.6284289 18.3992371 33 18.3992371 33 13.5988557 33.6284289 13.5988557 33.6284289 13"></polygon>
<polygon id="Eye---Right" points="96.3948738 0 96.3948738 0.599046105 97 0.599046105 97 5.3990461 96.3948738 5.3990461 96.3948738 6 77.6051262 6 77.6051262 5.3990461 77 5.3990461 77 0.599046105 77.6051262 0.599046105 77.6051262 0"></polygon>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="136px" height="129px" viewBox="0 0 136 129" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>Ghost-Mild Copy</title>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Ghost-Mild-Copy" transform="translate(0.3333, 0)">
<g id="Bod" fill="#EA2E69">
<polygon id="Ectoball" points="59.75 0 59.75 1.79362973 50.7916667 1.79362973 50.7916667 3.58526875 43.6269907 3.58526875 43.6269907 7.16854678 34.6686574 7.16854678 34.6686574 10.7518248 27.5 10.7518248 27.5 16.1267419 22.125 16.1267419 22.125 21.5016589 16.7519907 21.5016589 16.7519907 28.668215 13.1666667 28.668215 13.1666667 37.6264101 9.58333333 37.6264101 9.58333333 44.7909754 7.79365741 44.7909754 7.79365741 53.7491705 6 53.7491705 6 75.2508295 7.79365741 75.2508295 7.79365741 84.2090246 9.58333333 84.2090246 9.58333333 91.3755806 13.1666667 91.3755806 13.1666667 100.333776 16.7519907 100.333776 16.7519907 107.498341 22.125 107.498341 22.125 112.873258 27.5 112.873258 27.5 118.250166 34.6686574 118.250166 34.6686574 121.831453 43.6269907 121.831453 43.6269907 125.414731 50.7916667 125.414731 50.7916667 127.208361 59.75 127.208361 59.75 129 81.25 129 81.25 127.208361 90.2083333 127.208361 90.2083333 125.414731 97.3769907 125.414731 97.3769907 121.831453 106.335324 121.831453 106.335324 118.250166 113.5 118.250166 113.5 112.873258 118.875 112.873258 118.875 107.498341 124.251991 107.498341 124.251991 100.333776 127.833333 100.333776 127.833333 91.3755806 131.416667 91.3755806 131.416667 84.2090246 133.210324 84.2090246 133.210324 75.2508295 135 75.2508295 135 53.7491705 133.210324 53.7491705 133.210324 44.7909754 131.416667 44.7909754 131.416667 37.6264101 127.833333 37.6264101 127.833333 28.668215 124.251991 28.668215 124.251991 21.5016589 118.875 21.5016589 118.875 16.1267419 113.5 16.1267419 113.5 10.7518248 106.335324 10.7518248 106.335324 7.16854678 97.3769907 7.16854678 97.3769907 3.58526875 90.2083333 3.58526875 90.2083333 1.79362973 81.25 1.79362973 81.25 0"></polygon>
<polygon id="Flagellum" points="1.35555319 82 1.35555319 83.3080672 0 83.3080672 0 98.9990253 1.35555319 98.9990253 1.35555319 100.309042 9.50101161 100.309042 9.50101161 104.231294 8.1434352 104.231294 8.1434352 106.847428 1.35555319 106.847428 1.35555319 108.153546 0 108.153546 0 114.693882 1.35555319 114.693882 1.35555319 116 10.8545416 116 10.8545416 114.693882 13.5696944 114.693882 13.5696944 112.079697 16.2848472 112.079697 16.2848472 109.463563 17.6444468 109.463563 17.6444468 104.231294 19 104.231294 19 83.3080672 17.6444468 83.3080672 17.6444468 82"></polygon>
</g>
<g id="Mild-Face" transform="translate(50, 38)" fill="#000000">
<polygon id="Eye---Left" points="7.0724506 0.641835112 7.0724506 2.56938451 7.71428571 2.56938451 7.71428571 4.49897797 8.35816489 4.49897797 8.35816489 6.42652737 9 6.42652737 9 8.35612083 8.35816489 8.35612083 8.35816489 9 4.50102203 9 4.50102203 8.35612083 3.85918692 8.35612083 3.85918692 6.42652737 2.57142857 6.42652737 2.57142857 4.49897797 1.28571429 4.49897797 1.28571429 2.56938451 0 2.56938451 0 0.641835112 0.641835112 0.641835112 0.641835112 0 6.43061549 0 6.43061549 0.641835112"></polygon>
<polygon id="Mouth" points="40.3948738 25 40.3948738 25.5990461 41 25.5990461 41 30.3990461 40.3948738 30.3990461 40.3948738 31 21.6051262 31 21.6051262 30.3990461 21 30.3990461 21 25.5990461 21.6051262 25.5990461 21.6051262 25"></polygon>
<polygon id="Eye---Right" points="52.0724506 0.641835112 52.0724506 2.56938451 52.7142857 2.56938451 52.7142857 4.49897797 53.3581649 4.49897797 53.3581649 6.42652737 54 6.42652737 54 8.35612083 53.3581649 8.35612083 53.3581649 9 49.501022 9 49.501022 8.35612083 48.8591869 8.35612083 48.8591869 6.42652737 47.5714286 6.42652737 47.5714286 4.49897797 46.2857143 4.49897797 46.2857143 2.56938451 45 2.56938451 45 0.641835112 45.6418351 0.641835112 45.6418351 0 51.4306155 0 51.4306155 0.641835112"></polygon>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="136px" height="129px" viewBox="0 0 136 129" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>Ghost-Sweet Copy</title>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Ghost-Sweet-Copy" transform="translate(0.6667, 0)">
<g id="Bod" fill="#2FE17F">
<polygon id="Ectoball" points="59.75 0 59.75 1.79362973 50.7916667 1.79362973 50.7916667 3.58526875 43.6269907 3.58526875 43.6269907 7.16854678 34.6686574 7.16854678 34.6686574 10.7518248 27.5 10.7518248 27.5 16.1267419 22.125 16.1267419 22.125 21.5016589 16.7519907 21.5016589 16.7519907 28.668215 13.1666667 28.668215 13.1666667 37.6264101 9.58333333 37.6264101 9.58333333 44.7909754 7.79365741 44.7909754 7.79365741 53.7491705 6 53.7491705 6 75.2508295 7.79365741 75.2508295 7.79365741 84.2090246 9.58333333 84.2090246 9.58333333 91.3755806 13.1666667 91.3755806 13.1666667 100.333776 16.7519907 100.333776 16.7519907 107.498341 22.125 107.498341 22.125 112.873258 27.5 112.873258 27.5 118.250166 34.6686574 118.250166 34.6686574 121.831453 43.6269907 121.831453 43.6269907 125.414731 50.7916667 125.414731 50.7916667 127.208361 59.75 127.208361 59.75 129 81.25 129 81.25 127.208361 90.2083333 127.208361 90.2083333 125.414731 97.3769907 125.414731 97.3769907 121.831453 106.335324 121.831453 106.335324 118.250166 113.5 118.250166 113.5 112.873258 118.875 112.873258 118.875 107.498341 124.251991 107.498341 124.251991 100.333776 127.833333 100.333776 127.833333 91.3755806 131.416667 91.3755806 131.416667 84.2090246 133.210324 84.2090246 133.210324 75.2508295 135 75.2508295 135 53.7491705 133.210324 53.7491705 133.210324 44.7909754 131.416667 44.7909754 131.416667 37.6264101 127.833333 37.6264101 127.833333 28.668215 124.251991 28.668215 124.251991 21.5016589 118.875 21.5016589 118.875 16.1267419 113.5 16.1267419 113.5 10.7518248 106.335324 10.7518248 106.335324 7.16854678 97.3769907 7.16854678 97.3769907 3.58526875 90.2083333 3.58526875 90.2083333 1.79362973 81.25 1.79362973 81.25 0"></polygon>
<polygon id="Flagellum" points="1.35555319 82 1.35555319 83.3080672 0 83.3080672 0 98.9990253 1.35555319 98.9990253 1.35555319 100.309042 9.50101161 100.309042 9.50101161 104.231294 8.1434352 104.231294 8.1434352 106.847428 1.35555319 106.847428 1.35555319 108.153546 0 108.153546 0 114.693882 1.35555319 114.693882 1.35555319 116 10.8545416 116 10.8545416 114.693882 13.5696944 114.693882 13.5696944 112.079697 16.2848472 112.079697 16.2848472 109.463563 17.6444468 109.463563 17.6444468 104.231294 19 104.231294 19 83.3080672 17.6444468 83.3080672 17.6444468 82"></polygon>
</g>
<g id="Sweet-Face" transform="translate(37, 38)" fill="#000000">
<polygon id="Eye---Left" points="23 19.6117369 23 21.3724564 22.3585576 21.3724564 22.3585576 22 16.3179678 22 16.3179678 21.3724564 15.6765254 21.3724564 15.6765254 17.5392442 14.5202949 17.5392442 14.5202949 14.331577 13.5560885 14.331577 13.5560885 10.184593 12.5285549 10.184593 12.5285549 7.10483285 10.4734879 7.10483285 10.4734879 10.184593 9.44391154 10.184593 9.44391154 14.331577 8.48174793 14.331577 8.48174793 17.5392442 7.32551736 17.5392442 7.32551736 21.3724564 6.68203215 21.3724564 6.68203215 22 0.643485212 22 0.643485212 21.3724564 0 21.3724564 0 19.6117369 1.02753353 19.6117369 1.02753353 16.2801599 2.18580691 16.2801599 2.18580691 13.2643532 3.14797051 13.2643532 3.14797051 10.184593 4.17550404 10.184593 4.17550404 7.10483285 5.65449862 7.10483285 5.65449862 3.08175872 7.06812328 3.08175872 7.06812328 0.629542151 7.71160849 0.629542151 7.71160849 0 15.2924771 0 15.2924771 0.629542151 15.9339195 0.629542151 15.9339195 3.08175872 17.3455014 3.08175872 17.3455014 7.10483285 18.824496 7.10483285 18.824496 10.184593 19.8520295 10.184593 19.8520295 13.2643532 20.8182787 13.2643532 20.8182787 16.2801599 21.9724665 16.2801599 21.9724665 19.6117369"></polygon>
<polygon id="Mouth" points="50 35.6418351 50 43.3561208 49.3581649 43.3561208 49.3581649 44 41.6418351 44 41.6418351 43.3561208 41 43.3561208 41 35.6418351 41.6418351 35.6418351 41.6418351 35 49.3581649 35 49.3581649 35.6418351"></polygon>
<polygon id="Eye---Right" points="79 19.6117369 79 21.3724564 78.3585576 21.3724564 78.3585576 22 72.3179678 22 72.3179678 21.3724564 71.6765254 21.3724564 71.6765254 17.5392442 70.5202949 17.5392442 70.5202949 14.331577 69.5560885 14.331577 69.5560885 10.184593 68.5285549 10.184593 68.5285549 7.10483285 66.4734879 7.10483285 66.4734879 10.184593 65.4439115 10.184593 65.4439115 14.331577 64.4817479 14.331577 64.4817479 17.5392442 63.3255174 17.5392442 63.3255174 21.3724564 62.6820322 21.3724564 62.6820322 22 56.6434852 22 56.6434852 21.3724564 56 21.3724564 56 19.6117369 57.0275335 19.6117369 57.0275335 16.2801599 58.1858069 16.2801599 58.1858069 13.2643532 59.1479705 13.2643532 59.1479705 10.184593 60.175504 10.184593 60.175504 7.10483285 61.6544986 7.10483285 61.6544986 3.08175872 63.0681233 3.08175872 63.0681233 0.629542151 63.7116085 0.629542151 63.7116085 0 71.2924771 0 71.2924771 0.629542151 71.9339195 0.629542151 71.9339195 3.08175872 73.3455014 3.08175872 73.3455014 7.10483285 74.824496 7.10483285 74.824496 10.184593 75.8520295 10.184593 75.8520295 13.2643532 76.8182787 13.2643532 76.8182787 16.2801599 77.9724665 16.2801599 77.9724665 19.6117369"></polygon>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="136px" height="129px" viewBox="0 0 136 129" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>Ghost-WTF Copy</title>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Ghost-WTF-Copy" transform="translate(0.3333, 0)">
<g id="Bod" fill="#0088A5">
<polygon id="Ectoball" points="59.75 0 59.75 1.79362973 50.7916667 1.79362973 50.7916667 3.58526875 43.6269907 3.58526875 43.6269907 7.16854678 34.6686574 7.16854678 34.6686574 10.7518248 27.5 10.7518248 27.5 16.1267419 22.125 16.1267419 22.125 21.5016589 16.7519907 21.5016589 16.7519907 28.668215 13.1666667 28.668215 13.1666667 37.6264101 9.58333333 37.6264101 9.58333333 44.7909754 7.79365741 44.7909754 7.79365741 53.7491705 6 53.7491705 6 75.2508295 7.79365741 75.2508295 7.79365741 84.2090246 9.58333333 84.2090246 9.58333333 91.3755806 13.1666667 91.3755806 13.1666667 100.333776 16.7519907 100.333776 16.7519907 107.498341 22.125 107.498341 22.125 112.873258 27.5 112.873258 27.5 118.250166 34.6686574 118.250166 34.6686574 121.831453 43.6269907 121.831453 43.6269907 125.414731 50.7916667 125.414731 50.7916667 127.208361 59.75 127.208361 59.75 129 81.25 129 81.25 127.208361 90.2083333 127.208361 90.2083333 125.414731 97.3769907 125.414731 97.3769907 121.831453 106.335324 121.831453 106.335324 118.250166 113.5 118.250166 113.5 112.873258 118.875 112.873258 118.875 107.498341 124.251991 107.498341 124.251991 100.333776 127.833333 100.333776 127.833333 91.3755806 131.416667 91.3755806 131.416667 84.2090246 133.210324 84.2090246 133.210324 75.2508295 135 75.2508295 135 53.7491705 133.210324 53.7491705 133.210324 44.7909754 131.416667 44.7909754 131.416667 37.6264101 127.833333 37.6264101 127.833333 28.668215 124.251991 28.668215 124.251991 21.5016589 118.875 21.5016589 118.875 16.1267419 113.5 16.1267419 113.5 10.7518248 106.335324 10.7518248 106.335324 7.16854678 97.3769907 7.16854678 97.3769907 3.58526875 90.2083333 3.58526875 90.2083333 1.79362973 81.25 1.79362973 81.25 0"></polygon>
<polygon id="Flagellum" points="1.35555319 82 1.35555319 83.3080672 0 83.3080672 0 98.9990253 1.35555319 98.9990253 1.35555319 100.309042 9.50101161 100.309042 9.50101161 104.231294 8.1434352 104.231294 8.1434352 106.847428 1.35555319 106.847428 1.35555319 108.153546 0 108.153546 0 114.693882 1.35555319 114.693882 1.35555319 116 10.8545416 116 10.8545416 114.693882 13.5696944 114.693882 13.5696944 112.079697 16.2848472 112.079697 16.2848472 109.463563 17.6444468 109.463563 17.6444468 104.231294 19 104.231294 19 83.3080672 17.6444468 83.3080672 17.6444468 82"></polygon>
</g>
<g id="WTF-Face" transform="translate(27, 38)" fill="#000000">
<path d="M14.8,38.7482888 L22.2,38.7482888 L22.2,38.12431 L24.0509804,38.12431 L24.0509804,36.8743652 L25.9,36.8743652 L25.9,35.6244204 L27.1333333,35.6244204 L27.1333333,33.7465224 L28.3666667,33.7465224 L28.3666667,31.24862 L29.6,31.24862 L29.6,28.1227644 L30.2156863,28.1227644 L30.2156863,16.8752484 L29.6,16.8752484 L29.6,13.7493928 L28.3666667,13.7493928 L28.3666667,11.2495032 L27.1333333,11.2495032 L27.1333333,9.3755796 L25.9,9.3755796 L25.9,8.1256348 L24.0509804,8.1256348 L24.0509804,6.8737028 L22.2,6.8737028 L22.2,6.249724 L14.8,6.249724 L14.8,6.8737028 L12.9509804,6.8737028 L12.9509804,8.1256348 L11.1,8.1256348 L11.1,9.3755796 L9.86666667,9.3755796 L9.86666667,11.2495032 L8.63333333,11.2495032 L8.63333333,13.7493928 L7.40196078,13.7493928 L7.40196078,16.8752484 L6.78431373,16.8752484 L6.78431373,28.1227644 L7.40196078,28.1227644 L7.40196078,31.24862 L8.63333333,31.24862 L8.63333333,33.7465224 L9.86666667,33.7465224 L9.86666667,35.6244204 L11.1,35.6244204 L11.1,36.8743652 L12.9509804,36.8743652 L12.9509804,38.12431 L14.8,38.12431 L14.8,38.7482888 Z M37,16.2512696 L37,28.7487304 L36.3843137,28.7487304 L36.3843137,32.4965776 L35.1509804,32.4965776 L35.1509804,35.6244204 L33.9176471,35.6244204 L33.9176471,37.498344 L32.6843137,37.498344 L32.6843137,39.3722676 L31.4509804,39.3722676 L31.4509804,40.6222124 L30.2156863,40.6222124 L30.2156863,41.8741444 L28.3666667,41.8741444 L28.3666667,43.1240892 L25.9,43.1240892 L25.9,44.374034 L22.8176471,44.374034 L22.8176471,45 L14.1843137,45 L14.1843137,44.374034 L11.1,44.374034 L11.1,43.1240892 L8.63333333,43.1240892 L8.63333333,41.8741444 L6.78431373,41.8741444 L6.78431373,40.6222124 L5.55098039,40.6222124 L5.55098039,39.3722676 L4.31764706,39.3722676 L4.31764706,37.498344 L3.08235294,37.498344 L3.08235294,35.6244204 L1.85098039,35.6244204 L1.85098039,32.4965776 L0.617647059,32.4965776 L0.617647059,28.7487304 L0,28.7487304 L0,16.2512696 L0.617647059,16.2512696 L0.617647059,12.499448 L1.85098039,12.499448 L1.85098039,9.3755796 L3.08235294,9.3755796 L3.08235294,7.49768161 L4.31764706,7.49768161 L4.31764706,5.623758 L5.55098039,5.623758 L5.55098039,4.3738132 L6.78431373,4.3738132 L6.78431373,3.1238684 L8.63333333,3.1238684 L8.63333333,1.8739236 L11.1,1.8739236 L11.1,0.623978803 L14.1843137,0.623978803 L14.1843137,0 L22.8176471,0 L22.8176471,0.623978803 L25.9,0.623978803 L25.9,1.8739236 L28.3666667,1.8739236 L28.3666667,3.1238684 L30.2156863,3.1238684 L30.2156863,4.3738132 L31.4509804,4.3738132 L31.4509804,5.623758 L32.6843137,5.623758 L32.6843137,7.49768161 L33.9176471,7.49768161 L33.9176471,9.3755796 L35.1509804,9.3755796 L35.1509804,12.499448 L36.3843137,12.499448 L36.3843137,16.2512696 L37,16.2512696 Z" id="Eye---Left"></path>
<polygon id="Mouth" points="70.3735661 38 70.3735661 38.5988557 71 38.5988557 71 43.3992371 70.3735661 43.3992371 70.3735661 44 39.6284289 44 39.6284289 43.3992371 39 43.3992371 39 38.5988557 39.6284289 38.5988557 39.6284289 38"></polygon>
<path d="M90.6541785,38.150159 L92.5444805,38.150159 L92.5444805,36.9049133 L93.8053501,36.9049133 L93.8053501,35.0380347 L94.4367872,35.0380347 L94.4367872,32.5455636 L95.0662197,32.5455636 L95.0662197,24.4524567 L94.4367872,24.4524567 L94.4367872,21.9619653 L93.8053501,21.9619653 L93.8053501,20.0950867 L92.5444805,20.0950867 L92.5444805,18.849841 L90.6541785,18.849841 L90.6541785,18.2282081 L84.3478261,18.2282081 L84.3478261,18.849841 L82.457524,18.849841 L82.457524,20.0950867 L81.1966545,20.0950867 L81.1966545,21.9619653 L80.567222,21.9619653 L80.567222,24.4524567 L79.9357849,24.4524567 L79.9357849,32.5455636 L80.567222,32.5455636 L80.567222,35.0380347 L81.1966545,35.0380347 L81.1966545,36.9049133 L82.457524,36.9049133 L82.457524,38.150159 L84.3478261,38.150159 L84.3478261,38.7717919 L90.6541785,38.7717919 L90.6541785,38.150159 Z M91.283611,12 L91.283611,12.621633 L93.8053501,12.621633 L93.8053501,13.8668786 L96.3270892,13.8668786 L96.3270892,15.114104 L98.2173913,15.114104 L98.2173913,16.3593497 L99.4782609,16.3593497 L99.4782609,18.2282081 L100.73913,18.2282081 L100.73913,20.7167197 L101.370567,20.7167197 L101.370567,23.8308237 L102,23.8308237 L102,33.1671966 L101.370567,33.1671966 L101.370567,36.2832803 L100.73913,36.2832803 L100.73913,38.7717919 L99.4782609,38.7717919 L99.4782609,40.6406503 L98.2173913,40.6406503 L98.2173913,41.8878757 L96.3270892,41.8878757 L96.3270892,43.1331214 L93.8053501,43.1331214 L93.8053501,44.378367 L91.283611,44.378367 L91.283611,45 L83.7183936,45 L83.7183936,44.378367 L81.1966545,44.378367 L81.1966545,43.1331214 L78.6749153,43.1331214 L78.6749153,41.8878757 L76.7826087,41.8878757 L76.7826087,40.6406503 L75.5217391,40.6406503 L75.5217391,38.7717919 L74.2608696,38.7717919 L74.2608696,36.2832803 L73.6314371,36.2832803 L73.6314371,33.1671966 L73,33.1671966 L73,23.8308237 L73.6314371,23.8308237 L73.6314371,20.7167197 L74.2608696,20.7167197 L74.2608696,18.2282081 L75.5217391,18.2282081 L75.5217391,16.3593497 L76.7826087,16.3593497 L76.7826087,15.114104 L78.6749153,15.114104 L78.6749153,13.8668786 L81.1966545,13.8668786 L81.1966545,12.621633 L83.7183936,12.621633 L83.7183936,12 L91.283611,12 Z" id="Eye---Right"></path>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.9 KiB

58
scripts/fix-avatars.js Normal file
View file

@ -0,0 +1,58 @@
import mongoose from "mongoose";
import dotenv from "dotenv";
// Load environment variables
dotenv.config();
const memberSchema = new mongoose.Schema({
name: String,
avatar: String,
email: String,
});
const Member = mongoose.model("Member", memberSchema);
const validAvatars = [
"disbelieving",
"double-take",
"exasperated",
"mild",
"sweet",
];
async function fixAvatars() {
try {
await mongoose.connect(
process.env.NUXT_MONGODB_URI || process.env.MONGODB_URI,
);
console.log("Connected to MongoDB");
const members = await Member.find({ avatar: { $exists: true, $ne: "" } });
console.log(`\nFound ${members.length} members with avatars set`);
for (const member of members) {
if (!validAvatars.includes(member.avatar?.toLowerCase())) {
console.log(`\n❌ Invalid avatar found:`);
console.log(` Email: ${member.email}`);
console.log(` Name: ${member.name}`);
console.log(` Current avatar: "${member.avatar}"`);
// Set to a default valid avatar
member.avatar = "mild";
await member.save();
console.log(` ✅ Fixed to: "mild"`);
} else {
console.log(`${member.email} - avatar: "${member.avatar}" (valid)`);
}
}
await mongoose.disconnect();
console.log("\n✅ Done!");
process.exit(0);
} catch (error) {
console.error("Error:", error);
process.exit(1);
}
}
fixAvatars();

View file

@ -0,0 +1,187 @@
import { connectDB } from '../server/utils/mongoose.js'
import Event from '../server/models/event.js'
async function seedSeriesEvents() {
try {
await connectDB()
console.log('Connected to database')
// Workshop Series: "Cooperative Game Development Fundamentals"
const workshopSeries = [
{
title: 'Cooperative Business Models in Game Development',
slug: 'coop-business-models-workshop',
tagline: 'Learn the foundations of cooperative business structures',
description: 'An introductory workshop covering the basic principles and structures of worker cooperatives in the game development industry.',
content: 'This workshop will cover the legal structures, governance models, and financial frameworks that make cooperative game studios successful.',
startDate: new Date('2024-10-15T19:00:00.000Z'),
endDate: new Date('2024-10-15T21:00:00.000Z'),
eventType: 'workshop',
location: '#workshop-fundamentals',
isOnline: true,
membersOnly: false,
registrationRequired: true,
maxAttendees: 50,
series: {
id: 'coop-dev-fundamentals',
title: 'Cooperative Game Development Fundamentals',
description: 'A comprehensive workshop series covering the essentials of building and running cooperative game studios',
type: 'workshop_series',
position: 1,
totalEvents: 4,
isSeriesEvent: true
},
createdBy: 'admin'
},
{
title: 'Democratic Decision Making in Creative Projects',
slug: 'democratic-decision-making-workshop',
tagline: 'Practical tools for collaborative project management',
description: 'Learn how to implement democratic decision-making processes that work for creative teams and game development projects.',
content: 'This workshop focuses on consensus building, conflict resolution, and collaborative project management techniques.',
startDate: new Date('2024-10-22T19:00:00.000Z'),
endDate: new Date('2024-10-22T21:00:00.000Z'),
eventType: 'workshop',
location: '#workshop-fundamentals',
isOnline: true,
membersOnly: false,
registrationRequired: true,
maxAttendees: 50,
series: {
id: 'coop-dev-fundamentals',
title: 'Cooperative Game Development Fundamentals',
description: 'A comprehensive workshop series covering the essentials of building and running cooperative game studios',
type: 'workshop_series',
position: 2,
totalEvents: 4,
isSeriesEvent: true
},
createdBy: 'admin'
},
{
title: 'Funding and Financial Models for Co-ops',
slug: 'coop-funding-workshop',
tagline: 'Sustainable financing for cooperative studios',
description: 'Explore funding options, revenue sharing models, and financial management strategies specific to cooperative game studios.',
content: 'This workshop covers grant opportunities, crowdfunding strategies, and internal financial management for worker cooperatives.',
startDate: new Date('2024-10-29T19:00:00.000Z'),
endDate: new Date('2024-10-29T21:00:00.000Z'),
eventType: 'workshop',
location: '#workshop-fundamentals',
isOnline: true,
membersOnly: false,
registrationRequired: true,
maxAttendees: 50,
series: {
id: 'coop-dev-fundamentals',
title: 'Cooperative Game Development Fundamentals',
description: 'A comprehensive workshop series covering the essentials of building and running cooperative game studios',
type: 'workshop_series',
position: 3,
totalEvents: 4,
isSeriesEvent: true
},
createdBy: 'admin'
},
{
title: 'Building Your Cooperative Studio',
slug: 'building-coop-studio-workshop',
tagline: 'From concept to reality: launching your co-op',
description: 'A practical guide to forming a cooperative game studio, covering legal formation, member recruitment, and launch strategies.',
content: 'This final workshop in the series provides a step-by-step guide to actually forming and launching a cooperative game studio.',
startDate: new Date('2024-11-05T19:00:00.000Z'),
endDate: new Date('2024-11-05T21:00:00.000Z'),
eventType: 'workshop',
location: '#workshop-fundamentals',
isOnline: true,
membersOnly: false,
registrationRequired: true,
maxAttendees: 50,
series: {
id: 'coop-dev-fundamentals',
title: 'Cooperative Game Development Fundamentals',
description: 'A comprehensive workshop series covering the essentials of building and running cooperative game studios',
type: 'workshop_series',
position: 4,
totalEvents: 4,
isSeriesEvent: true
},
createdBy: 'admin'
}
]
// Monthly Community Meetup Series
const meetupSeries = [
{
title: 'October Community Meetup',
slug: 'october-community-meetup',
tagline: 'Monthly gathering for cooperative game developers',
description: 'Join fellow cooperative game developers for informal networking, project sharing, and community building.',
content: 'Our monthly community meetup provides a relaxed environment to share your projects, get feedback, and connect with other developers interested in cooperative models.',
startDate: new Date('2024-10-12T18:00:00.000Z'),
endDate: new Date('2024-10-12T20:00:00.000Z'),
eventType: 'community',
location: '#community-meetup',
isOnline: true,
membersOnly: false,
registrationRequired: false,
series: {
id: 'monthly-meetups',
title: 'Monthly Community Meetups',
description: 'Regular monthly gatherings for the cooperative game development community',
type: 'recurring_meetup',
position: 1,
totalEvents: 12,
isSeriesEvent: true
},
createdBy: 'admin'
},
{
title: 'November Community Meetup',
slug: 'november-community-meetup',
tagline: 'Monthly gathering for cooperative game developers',
description: 'Join fellow cooperative game developers for informal networking, project sharing, and community building.',
content: 'Our monthly community meetup provides a relaxed environment to share your projects, get feedback, and connect with other developers interested in cooperative models.',
startDate: new Date('2024-11-09T18:00:00.000Z'),
endDate: new Date('2024-11-09T20:00:00.000Z'),
eventType: 'community',
location: '#community-meetup',
isOnline: true,
membersOnly: false,
registrationRequired: false,
series: {
id: 'monthly-meetups',
title: 'Monthly Community Meetups',
description: 'Regular monthly gatherings for the cooperative game development community',
type: 'recurring_meetup',
position: 2,
totalEvents: 12,
isSeriesEvent: true
},
createdBy: 'admin'
}
]
// Insert all series events
const allSeriesEvents = [...workshopSeries, ...meetupSeries]
for (const eventData of allSeriesEvents) {
const existingEvent = await Event.findOne({ slug: eventData.slug })
if (!existingEvent) {
const event = new Event(eventData)
await event.save()
console.log(`Created series event: ${event.title}`)
} else {
console.log(`Series event already exists: ${eventData.title}`)
}
}
console.log('Series events seeding completed!')
process.exit(0)
} catch (error) {
console.error('Error seeding series events:', error)
process.exit(1)
}
}
seedSeriesEvents()

View file

@ -0,0 +1,121 @@
// Script to create missing Helcim payment plans
// Run with: node scripts/setup-helcim-plans.js
import { config } from "dotenv";
import { fileURLToPath } from "url";
import { dirname, resolve } from "path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Load .env file
config({ path: resolve(__dirname, "../.env") });
const HELCIM_API_BASE = "https://api.helcim.com/v2";
async function createPlan(name, amount) {
const helcimToken = process.env.NUXT_PUBLIC_HELCIM_TOKEN;
if (!helcimToken) {
throw new Error(
"NUXT_PUBLIC_HELCIM_TOKEN environment variable not set in .env file",
);
}
console.log(`Creating plan: ${name} ($${amount}/month)...`);
const response = await fetch(`${HELCIM_API_BASE}/payment-plans`, {
method: "POST",
headers: {
accept: "application/json",
"content-type": "application/json",
"api-token": helcimToken,
},
body: JSON.stringify({
name: name,
description: `Ghost Guild ${name}`,
type: "subscription",
status: "active",
currency: "CAD",
setupAmount: 0,
recurringAmount: parseFloat(amount),
billingPeriod: "monthly",
billingPeriodIncrements: 1,
dateBilling: "Sign-up",
termType: "forever",
freeTrialPeriod: 0,
taxType: "customer",
paymentMethod: "card",
}),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to create plan: ${errorText}`);
}
const planData = await response.json();
console.log(`✓ Created plan ID: ${planData.id}`);
return planData;
}
async function main() {
console.log("Setting up Helcim payment plans...\n");
const plans = [
{ name: "Ghost Guild - Member ($15)", amount: 15, tier: "MEMBER" },
{ name: "Ghost Guild - Advocate ($30)", amount: 30, tier: "ADVOCATE" },
{ name: "Ghost Guild - Champion ($50)", amount: 50, tier: "CHAMPION" },
];
const results = [];
for (const plan of plans) {
try {
const result = await createPlan(plan.name, plan.amount);
results.push({
tier: plan.tier,
planId: result.id,
amount: plan.amount,
});
} catch (error) {
console.error(`✗ Failed to create ${plan.name}:`, error.message);
}
}
console.log("\n--- Plan IDs to update in contributions.js ---");
results.forEach(({ tier, planId, amount }) => {
console.log(`${tier}: helcimPlanId: ${planId}, // $${amount}/month`);
});
console.log("\n--- Copy this to update server/config/contributions.js ---");
console.log(`
MEMBER: {
value: '15',
amount: 15,
label: '$15 - I can sustain the community',
tier: 'member',
helcimPlanId: ${results.find((r) => r.tier === "MEMBER")?.planId || "null"},
...
},
ADVOCATE: {
value: '30',
amount: 30,
label: '$30 - I can support others too',
tier: 'advocate',
helcimPlanId: ${results.find((r) => r.tier === "ADVOCATE")?.planId || "null"},
...
},
CHAMPION: {
value: '50',
amount: 50,
label: '$50 - I want to sponsor multiple members',
tier: 'champion',
helcimPlanId: ${results.find((r) => r.tier === "CHAMPION")?.planId || "null"},
...
}
`);
}
main().catch(console.error);

View file

@ -29,13 +29,32 @@ export default defineEventHandler(async (event) => {
await connectDB() await connectDB()
const newEvent = new Event({ const eventData = {
...body, ...body,
createdBy: 'admin@ghostguild.org', // TODO: Use actual authenticated user createdBy: 'admin@ghostguild.org', // TODO: Use actual authenticated user
startDate: new Date(body.startDate), startDate: new Date(body.startDate),
endDate: new Date(body.endDate), endDate: new Date(body.endDate),
registrationDeadline: body.registrationDeadline ? new Date(body.registrationDeadline) : null registrationDeadline: body.registrationDeadline ? new Date(body.registrationDeadline) : null
}) }
// Handle ticket data
if (body.tickets) {
eventData.tickets = {
enabled: body.tickets.enabled || false,
public: {
available: body.tickets.public?.available || false,
name: body.tickets.public?.name || 'Public Ticket',
description: body.tickets.public?.description || '',
price: body.tickets.public?.price || 0,
quantity: body.tickets.public?.quantity || null,
sold: 0, // Initialize sold count
earlyBirdPrice: body.tickets.public?.earlyBirdPrice || null,
earlyBirdDeadline: body.tickets.public?.earlyBirdDeadline ? new Date(body.tickets.public.earlyBirdDeadline) : null
}
}
}
const newEvent = new Event(eventData)
const savedEvent = await newEvent.save() const savedEvent = await newEvent.save()

View file

@ -37,6 +37,23 @@ export default defineEventHandler(async (event) => {
registrationDeadline: body.registrationDeadline ? new Date(body.registrationDeadline) : null, registrationDeadline: body.registrationDeadline ? new Date(body.registrationDeadline) : null,
updatedAt: new Date() updatedAt: new Date()
} }
// Handle ticket data
if (body.tickets) {
updateData.tickets = {
enabled: body.tickets.enabled || false,
public: {
available: body.tickets.public?.available || false,
name: body.tickets.public?.name || 'Public Ticket',
description: body.tickets.public?.description || '',
price: body.tickets.public?.price || 0,
quantity: body.tickets.public?.quantity || null,
sold: body.tickets.public?.sold || 0,
earlyBirdPrice: body.tickets.public?.earlyBirdPrice || null,
earlyBirdDeadline: body.tickets.public?.earlyBirdDeadline ? new Date(body.tickets.public.earlyBirdDeadline) : null
}
}
}
const updatedEvent = await Event.findByIdAndUpdate( const updatedEvent = await Event.findByIdAndUpdate(
eventId, eventId,

View file

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

View file

@ -0,0 +1,49 @@
import Series from '../../models/series.js'
import { connectDB } from '../../utils/mongoose.js'
export default defineEventHandler(async (event) => {
try {
await connectDB()
const body = await readBody(event)
// Validate required fields
if (!body.id || !body.title || !body.description) {
throw createError({
statusCode: 400,
statusMessage: 'Series ID, title, and description are required'
})
}
// Create new series
const newSeries = new Series({
id: body.id,
title: body.title,
description: body.description,
type: body.type || 'workshop_series',
totalEvents: body.totalEvents || null,
createdBy: 'admin' // TODO: Get from authentication
})
await newSeries.save()
return {
success: true,
data: newSeries
}
} catch (error) {
console.error('Error creating series:', error)
if (error.code === 11000) {
throw createError({
statusCode: 400,
statusMessage: 'A series with this ID already exists'
})
}
throw createError({
statusCode: 500,
statusMessage: error.message || 'Failed to create series'
})
}
})

View file

@ -0,0 +1,58 @@
import Series from '../../../models/series.js'
import Event from '../../../models/event.js'
import { connectDB } from '../../../utils/mongoose.js'
export default defineEventHandler(async (event) => {
try {
await connectDB()
const id = getRouterParam(event, 'id')
if (!id) {
throw createError({
statusCode: 400,
statusMessage: 'Series ID is required'
})
}
// Find the series
const series = await Series.findOne({ id: id })
if (!series) {
throw createError({
statusCode: 404,
statusMessage: 'Series not found'
})
}
// Remove series relationship from all related events
await Event.updateMany(
{ 'series.id': id, 'series.isSeriesEvent': true },
{
$set: {
'series.isSeriesEvent': false,
'series.id': '',
'series.title': '',
'series.description': '',
'series.type': 'workshop_series',
'series.position': 1,
'series.totalEvents': null
}
}
)
// Delete the series
await Series.deleteOne({ id: id })
return {
success: true,
message: 'Series deleted and events converted to standalone events'
}
} catch (error) {
console.error('Error deleting series:', error)
throw createError({
statusCode: 500,
statusMessage: error.message || 'Failed to delete series'
})
}
})

View file

@ -0,0 +1,62 @@
import Series from '../../../models/series.js'
import Event from '../../../models/event.js'
import { connectDB } from '../../../utils/mongoose.js'
export default defineEventHandler(async (event) => {
try {
await connectDB()
const id = getRouterParam(event, 'id')
const body = await readBody(event)
if (!id) {
throw createError({
statusCode: 400,
statusMessage: 'Series ID is required'
})
}
// Find and update the series
const series = await Series.findOne({ id: id })
if (!series) {
throw createError({
statusCode: 404,
statusMessage: 'Series not found'
})
}
// Update series fields
if (body.title !== undefined) series.title = body.title
if (body.description !== undefined) series.description = body.description
if (body.type !== undefined) series.type = body.type
if (body.totalEvents !== undefined) series.totalEvents = body.totalEvents
if (body.isActive !== undefined) series.isActive = body.isActive
await series.save()
// Also update all related events with the new series information
await Event.updateMany(
{ 'series.id': id, 'series.isSeriesEvent': true },
{
$set: {
'series.title': series.title,
'series.description': series.description,
'series.type': series.type,
'series.totalEvents': series.totalEvents
}
}
)
return {
success: true,
data: series
}
} catch (error) {
console.error('Error updating series:', error)
throw createError({
statusCode: 500,
statusMessage: error.message || 'Failed to update series'
})
}
})

View file

@ -1,55 +1,57 @@
// server/api/auth/login.post.js // server/api/auth/login.post.js
import jwt from 'jsonwebtoken' import jwt from "jsonwebtoken";
import { Resend } from 'resend' import { Resend } from "resend";
import Member from '../../models/member.js' import Member from "../../models/member.js";
import { connectDB } from '../../utils/mongoose.js' import { connectDB } from "../../utils/mongoose.js";
const resend = new Resend(process.env.RESEND_API_KEY) const resend = new Resend(process.env.RESEND_API_KEY);
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
// Connect to database // Connect to database
await connectDB() await connectDB();
const { email } = await readBody(event) const { email } = await readBody(event);
if (!email) { if (!email) {
throw createError({ throw createError({
statusCode: 400, statusCode: 400,
statusMessage: 'Email is required' statusMessage: "Email is required",
}) });
} }
const member = await Member.findOne({ email }) const member = await Member.findOne({ email });
if (!member) { if (!member) {
throw createError({ throw createError({
statusCode: 404, statusCode: 404,
statusMessage: 'No account found with that email address' statusMessage: "No account found with that email address",
}) });
} }
// Generate magic link token // Generate magic link token
const token = jwt.sign( const token = jwt.sign(
{ memberId: member._id }, { memberId: member._id },
process.env.JWT_SECRET, process.env.JWT_SECRET,
{ expiresIn: '15m' } // Shorter expiry for security { expiresIn: "15m" }, // Shorter expiry for security
) );
// Get the base URL for the magic link // Get the base URL for the magic link
const headers = getHeaders(event) const headers = getHeaders(event);
const baseUrl = process.env.BASE_URL || `${headers.host?.includes('localhost') ? 'http' : 'https'}://${headers.host}` const baseUrl =
process.env.BASE_URL ||
`${headers.host?.includes("localhost") ? "http" : "https"}://${headers.host}`;
// Send magic link via Resend // Send magic link via Resend
try { try {
await resend.emails.send({ await resend.emails.send({
from: 'Ghost Guild <noreply@ghostguild.org>', from: "Ghost Guild <ghostguild@babyghosts.org>",
to: email, to: email,
subject: 'Your Ghost Guild login link', subject: "Your Ghost Guild login link",
html: ` html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;"> <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #2563eb;">Welcome back to Ghost Guild!</h2> <h2 style="color: #2563eb;">Welcome back to Ghost Guild!</h2>
<p>Click the button below to sign in to your account:</p> <p>Click the button below to sign in to your account:</p>
<div style="text-align: center; margin: 30px 0;"> <div style="text-align: center; margin: 30px 0;">
<a href="${baseUrl}/api/auth/verify?token=${token}" <a href="${baseUrl}/api/auth/verify?token=${token}"
style="background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;"> style="background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">
Sign In to Ghost Guild Sign In to Ghost Guild
</a> </a>
@ -58,19 +60,18 @@ export default defineEventHandler(async (event) => {
This link will expire in 15 minutes for security. If you didn't request this login link, you can safely ignore this email. This link will expire in 15 minutes for security. If you didn't request this login link, you can safely ignore this email.
</p> </p>
</div> </div>
` `,
}) });
return { return {
success: true, success: true,
message: 'Login link sent to your email' message: "Login link sent to your email",
} };
} catch (error) { } catch (error) {
console.error('Failed to send email:', error) console.error("Failed to send email:", error);
throw createError({ throw createError({
statusCode: 500, statusCode: 500,
statusMessage: 'Failed to send login email. Please try again.' statusMessage: "Failed to send login email. Please try again.",
}) });
} }
}) });

View file

@ -1,9 +1,9 @@
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
// Clear the auth token cookie // Clear the auth token cookie
setCookie(event, 'auth-token', '', { setCookie(event, 'auth-token', '', {
httpOnly: true, httpOnly: false, // Match the original cookie settings
secure: process.env.NODE_ENV === 'production', secure: false, // Don't require HTTPS in development
sameSite: 'strict', sameSite: 'lax',
maxAge: 0 // Expire immediately maxAge: 0 // Expire immediately
}) })

View file

@ -0,0 +1,59 @@
import jwt from "jsonwebtoken";
import Member from "../../models/member.js";
import { connectDB } from "../../utils/mongoose.js";
export default defineEventHandler(async (event) => {
await connectDB();
const token = getCookie(event, "auth-token");
console.log("Auth check - token found:", !!token);
if (!token) {
console.log("No auth token found in cookies");
throw createError({
statusCode: 401,
statusMessage: "Not authenticated",
});
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const member = await Member.findById(decoded.memberId).select("-__v");
if (!member) {
throw createError({
statusCode: 404,
statusMessage: "Member not found",
});
}
return {
_id: member._id,
id: member._id,
email: member.email,
name: member.name,
circle: member.circle,
contributionTier: member.contributionTier,
membershipLevel: `${member.circle}-${member.contributionTier}`,
// Profile fields
pronouns: member.pronouns,
timeZone: member.timeZone,
avatar: member.avatar,
studio: member.studio,
bio: member.bio,
skills: member.skills,
location: member.location,
socialLinks: member.socialLinks,
offering: member.offering,
lookingFor: member.lookingFor,
showInDirectory: member.showInDirectory,
privacy: member.privacy,
};
} catch (err) {
console.error("Token verification error:", err);
throw createError({
statusCode: 401,
statusMessage: "Invalid or expired token",
});
}
});

View file

@ -0,0 +1,40 @@
import jwt from 'jsonwebtoken'
import Member from '../../models/member.js'
import { connectDB } from '../../utils/mongoose.js'
export default defineEventHandler(async (event) => {
await connectDB()
const token = getCookie(event, 'auth-token')
console.log('🔍 Auth status check - token exists:', !!token)
if (!token) {
return { authenticated: false, member: null }
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET)
const member = await Member.findById(decoded.memberId).select('-__v')
if (!member) {
console.log('⚠️ Token valid but member not found')
return { authenticated: false, member: null }
}
console.log('✅ Auth status check - member found:', member.email)
return {
authenticated: true,
member: {
id: member._id,
email: member.email,
name: member.name,
circle: member.circle,
contributionTier: member.contributionTier,
membershipLevel: `${member.circle}-${member.contributionTier}`
}
}
} catch (err) {
console.error('❌ Auth status check - token verification failed:', err.message)
return { authenticated: false, member: null }
}
})

View file

@ -38,8 +38,8 @@ export default defineEventHandler(async (event) => {
// Set the session cookie // Set the session cookie
setCookie(event, 'auth-token', sessionToken, { setCookie(event, 'auth-token', sessionToken, {
httpOnly: true, httpOnly: false, // Allow JavaScript access for debugging in development
secure: process.env.NODE_ENV === 'production', secure: false, // Don't require HTTPS in development
sameSite: 'lax', sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30 // 30 days maxAge: 60 * 60 * 24 * 30 // 30 days
}) })

View file

@ -0,0 +1,19 @@
// Get available contribution options
import { getContributionOptions } from '../../config/contributions.js'
export default defineEventHandler(async (event) => {
try {
const options = getContributionOptions()
return {
success: true,
options
}
} catch (error) {
console.error('Error fetching contribution options:', error)
throw createError({
statusCode: 500,
statusMessage: 'Failed to fetch contribution options'
})
}
})

View file

@ -0,0 +1,69 @@
import Event from '../../../models/event';
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id');
const body = await readBody(event);
const { email } = body;
if (!email) {
throw createError({
statusCode: 400,
statusMessage: 'Email is required'
});
}
try {
// Check if id is a valid ObjectId or treat as slug
const isObjectId = /^[0-9a-fA-F]{24}$/.test(id);
const query = isObjectId
? { $or: [{ _id: id }, { slug: id }] }
: { slug: id };
const eventDoc = await Event.findOne(query);
if (!eventDoc) {
throw createError({
statusCode: 404,
statusMessage: 'Event not found'
});
}
// Find the registration index
const registrationIndex = eventDoc.registrations.findIndex(
registration => registration.email.toLowerCase() === email.toLowerCase()
);
if (registrationIndex === -1) {
throw createError({
statusCode: 404,
statusMessage: 'Registration not found'
});
}
// Remove the registration
eventDoc.registrations.splice(registrationIndex, 1);
// Update registered count
eventDoc.registeredCount = eventDoc.registrations.length;
await eventDoc.save();
return {
success: true,
message: 'Registration cancelled successfully',
registeredCount: eventDoc.registeredCount
};
} catch (error) {
console.error('Error cancelling registration:', error);
// Re-throw known errors
if (error.statusCode) {
throw error;
}
throw createError({
statusCode: 500,
statusMessage: 'Failed to cancel registration'
});
}
});

View file

@ -0,0 +1,49 @@
import Event from "../../../models/event";
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, "id");
const body = await readBody(event);
const { email } = body;
if (!email) {
throw createError({
statusCode: 400,
statusMessage: "Email is required",
});
}
try {
// Check if id is a valid ObjectId or treat as slug
const isObjectId = /^[0-9a-fA-F]{24}$/.test(id);
const query = isObjectId
? { $or: [{ _id: id }, { slug: id }] }
: { slug: id };
const eventDoc = await Event.findOne(query);
if (!eventDoc) {
throw createError({
statusCode: 404,
statusMessage: "Event not found",
});
}
// Check if the email exists in the registrations array
const isRegistered = eventDoc.registrations.some(
(registration) =>
registration.email.toLowerCase() === email.toLowerCase(),
);
return {
isRegistered,
eventId: eventDoc._id,
eventTitle: eventDoc.title,
};
} catch (error) {
console.error("Error checking registration:", error);
throw createError({
statusCode: 500,
statusMessage: "Failed to check registration status",
});
}
});

View file

@ -0,0 +1,112 @@
import Event from '../../../models/event.js'
import { connectDB } from '../../../utils/mongoose.js'
import mongoose from 'mongoose'
export default defineEventHandler(async (event) => {
try {
await connectDB()
const identifier = getRouterParam(event, 'id')
const body = await readBody(event)
if (!identifier) {
throw createError({
statusCode: 400,
statusMessage: 'Event identifier is required'
})
}
// Validate required fields for guest registration
if (!body.name || !body.email) {
throw createError({
statusCode: 400,
statusMessage: 'Name and email are required'
})
}
// Fetch the event
let eventData
if (mongoose.Types.ObjectId.isValid(identifier)) {
eventData = await Event.findById(identifier)
}
if (!eventData) {
eventData = await Event.findOne({ slug: identifier })
}
if (!eventData) {
throw createError({
statusCode: 404,
statusMessage: 'Event not found'
})
}
// Check if event allows public registration (not members-only)
if (eventData.membersOnly) {
throw createError({
statusCode: 403,
statusMessage: 'This event is for members only. Please become a member to register.'
})
}
// If event requires payment, reject guest registration
if (eventData.pricing.paymentRequired && !eventData.pricing.isFree) {
throw createError({
statusCode: 402,
statusMessage: 'This event requires payment. Please use the payment registration endpoint.'
})
}
// Check if event is full
if (eventData.maxAttendees && eventData.registrations.length >= eventData.maxAttendees) {
throw createError({
statusCode: 400,
statusMessage: 'Event is full'
})
}
// Check if already registered
const alreadyRegistered = eventData.registrations.some(
reg => reg.email.toLowerCase() === body.email.toLowerCase()
)
if (alreadyRegistered) {
throw createError({
statusCode: 400,
statusMessage: 'You are already registered for this event'
})
}
// Add guest registration
eventData.registrations.push({
name: body.name,
email: body.email.toLowerCase(),
membershipLevel: 'guest',
isMember: false,
paymentStatus: 'not_required',
amountPaid: 0,
registeredAt: new Date()
})
await eventData.save()
// TODO: Send confirmation email for guest registration
return {
success: true,
message: 'Successfully registered as guest',
registrationId: eventData.registrations[eventData.registrations.length - 1]._id,
note: 'As a guest, you have access to this free public event. Consider becoming a member for access to all events!'
}
} catch (error) {
console.error('Error with guest registration:', error)
if (error.statusCode) {
throw error
}
throw createError({
statusCode: 500,
statusMessage: 'Failed to register as guest'
})
}
})

View file

@ -0,0 +1,136 @@
import Event from '../../../models/event.js'
import Member from '../../../models/member.js'
import { connectDB } from '../../../utils/mongoose.js'
import { processHelcimPayment } from '../../../utils/helcim.js'
import mongoose from 'mongoose'
export default defineEventHandler(async (event) => {
try {
await connectDB()
const identifier = getRouterParam(event, 'id')
const body = await readBody(event)
if (!identifier) {
throw createError({
statusCode: 400,
statusMessage: 'Event identifier is required'
})
}
// Validate required payment fields
if (!body.name || !body.email || !body.paymentToken) {
throw createError({
statusCode: 400,
statusMessage: 'Name, email, and payment token are required'
})
}
// Fetch the event
let eventData
if (mongoose.Types.ObjectId.isValid(identifier)) {
eventData = await Event.findById(identifier)
}
if (!eventData) {
eventData = await Event.findOne({ slug: identifier })
}
if (!eventData) {
throw createError({
statusCode: 404,
statusMessage: 'Event not found'
})
}
// Check if event requires payment
if (eventData.pricing.isFree || !eventData.pricing.paymentRequired) {
throw createError({
statusCode: 400,
statusMessage: 'This event does not require payment'
})
}
// Check if user is already registered
const existingRegistration = eventData.registrations.find(
reg => reg.email.toLowerCase() === body.email.toLowerCase()
)
if (existingRegistration) {
throw createError({
statusCode: 400,
statusMessage: 'You are already registered for this event'
})
}
// Check if user is a member (members get free access)
const member = await Member.findOne({ email: body.email.toLowerCase() })
if (member) {
// Members get free access - register directly without payment
eventData.registrations.push({
name: body.name,
email: body.email.toLowerCase(),
membershipLevel: `${member.circle}-${member.contributionTier}`,
isMember: true,
paymentStatus: 'not_required',
amountPaid: 0
})
await eventData.save()
return {
success: true,
message: 'Successfully registered as a member (no payment required)',
registration: eventData.registrations[eventData.registrations.length - 1]
}
}
// Process payment for non-members
const paymentResult = await processHelcimPayment({
amount: eventData.pricing.publicPrice,
paymentToken: body.paymentToken,
customerData: {
name: body.name,
email: body.email
}
})
if (!paymentResult.success) {
throw createError({
statusCode: 400,
statusMessage: paymentResult.message || 'Payment failed'
})
}
// Add registration with successful payment
eventData.registrations.push({
name: body.name,
email: body.email.toLowerCase(),
membershipLevel: 'non-member',
isMember: false,
paymentStatus: 'completed',
paymentId: paymentResult.transactionId,
amountPaid: eventData.pricing.publicPrice
})
await eventData.save()
return {
success: true,
message: 'Payment successful and registered for event',
paymentId: paymentResult.transactionId,
registration: eventData.registrations[eventData.registrations.length - 1]
}
} catch (error) {
console.error('Error processing event payment:', error)
if (error.statusCode) {
throw error
}
throw createError({
statusCode: 500,
statusMessage: 'Failed to process payment and registration'
})
}
})

View file

@ -1,116 +1,141 @@
import Event from '../../../models/event.js' import Event from "../../../models/event.js";
import Member from '../../../models/member.js' import Member from "../../../models/member.js";
import { connectDB } from '../../../utils/mongoose.js' import { connectDB } from "../../../utils/mongoose.js";
import mongoose from 'mongoose' import mongoose from "mongoose";
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
try { try {
// Ensure database connection // Ensure database connection
await connectDB() await connectDB();
const identifier = getRouterParam(event, 'id') const identifier = getRouterParam(event, "id");
const body = await readBody(event) const body = await readBody(event);
if (!identifier) { if (!identifier) {
throw createError({ throw createError({
statusCode: 400, statusCode: 400,
statusMessage: 'Event identifier is required' statusMessage: "Event identifier is required",
}) });
} }
// Validate required fields // Validate required fields
if (!body.name || !body.email) { if (!body.name || !body.email) {
throw createError({ throw createError({
statusCode: 400, statusCode: 400,
statusMessage: 'Name and email are required' statusMessage: "Name and email are required",
}) });
} }
// Fetch the event - try by slug first, then by ID // Fetch the event - try by slug first, then by ID
let eventData let eventData;
// Check if identifier is a valid MongoDB ObjectId // Check if identifier is a valid MongoDB ObjectId
if (mongoose.Types.ObjectId.isValid(identifier)) { if (mongoose.Types.ObjectId.isValid(identifier)) {
eventData = await Event.findById(identifier) eventData = await Event.findById(identifier);
} }
// If not found by ID or not a valid ObjectId, try by slug // If not found by ID or not a valid ObjectId, try by slug
if (!eventData) { if (!eventData) {
eventData = await Event.findOne({ slug: identifier }) eventData = await Event.findOne({ slug: identifier });
} }
if (!eventData) { if (!eventData) {
throw createError({ throw createError({
statusCode: 404, statusCode: 404,
statusMessage: 'Event not found' statusMessage: "Event not found",
}) });
} }
// Check if event is full // Check if event is full
if (eventData.maxAttendees && eventData.registrations.length >= eventData.maxAttendees) { if (
eventData.maxAttendees &&
eventData.registrations.length >= eventData.maxAttendees
) {
throw createError({ throw createError({
statusCode: 400, statusCode: 400,
statusMessage: 'Event is full' statusMessage: "Event is full",
}) });
} }
// Check if already registered // Check if already registered
const alreadyRegistered = eventData.registrations.some( const alreadyRegistered = eventData.registrations.some(
reg => reg.email.toLowerCase() === body.email.toLowerCase() (reg) => reg.email.toLowerCase() === body.email.toLowerCase(),
) );
if (alreadyRegistered) { if (alreadyRegistered) {
throw createError({ throw createError({
statusCode: 400, statusCode: 400,
statusMessage: 'You are already registered for this event' statusMessage: "You are already registered for this event",
}) });
} }
// Check member status if event is members-only // Check member status and handle different registration scenarios
if (eventData.membersOnly && body.membershipLevel === 'non-member') { const member = await Member.findOne({ email: body.email.toLowerCase() });
// Check if email belongs to a member
const member = await Member.findOne({ email: body.email.toLowerCase() }) if (eventData.membersOnly && !member) {
throw createError({
if (!member) { statusCode: 403,
throw createError({ statusMessage:
statusCode: 403, "This event is for members only. Please become a member to register.",
statusMessage: 'This event is for members only. Please become a member to register.' });
})
}
// Update membership level from database
body.membershipLevel = `${member.circle}-${member.contributionTier}`
} }
// If event requires payment and user is not a member, redirect to payment flow
if (
eventData.pricing.paymentRequired &&
!eventData.pricing.isFree &&
!member
) {
throw createError({
statusCode: 402, // Payment Required
statusMessage:
"This event requires payment. Please use the payment registration endpoint.",
});
}
// Set member status and membership level
let isMember = false;
let membershipLevel = "non-member";
if (member) {
isMember = true;
membershipLevel = `${member.circle}-${member.contributionTier}`;
}
// Add registration // Add registration
eventData.registrations.push({ eventData.registrations.push({
memberId: member ? member._id : null,
name: body.name, name: body.name,
email: body.email.toLowerCase(), email: body.email.toLowerCase(),
membershipLevel: body.membershipLevel || 'non-member', membershipLevel,
isMember,
paymentStatus: "not_required", // Free events or member registrations
amountPaid: 0,
dietary: body.dietary || false, dietary: body.dietary || false,
registeredAt: new Date() registeredAt: new Date(),
}) });
// Save the updated event // Save the updated event
await eventData.save() await eventData.save();
// TODO: Send confirmation email using Resend // TODO: Send confirmation email using Resend
// await sendEventRegistrationEmail(body.email, eventData) // await sendEventRegistrationEmail(body.email, eventData)
return { return {
success: true, success: true,
message: 'Successfully registered for the event', message: "Successfully registered for the event",
registrationId: eventData.registrations[eventData.registrations.length - 1]._id registrationId:
} eventData.registrations[eventData.registrations.length - 1]._id,
};
} catch (error) { } catch (error) {
console.error('Error registering for event:', error) console.error("Error registering for event:", error);
if (error.statusCode) { if (error.statusCode) {
throw error throw error;
} }
throw createError({ throw createError({
statusCode: 500, statusCode: 500,
statusMessage: 'Failed to register for event' statusMessage: "Failed to register for event",
}) });
} }
}) });

View file

@ -0,0 +1,61 @@
// Create a new Helcim payment plan
const HELCIM_API_BASE = 'https://api.helcim.com/v2'
export default defineEventHandler(async (event) => {
try {
const config = useRuntimeConfig(event)
const body = await readBody(event)
// Validate required fields
if (!body.name || !body.amount || !body.frequency) {
throw createError({
statusCode: 400,
statusMessage: 'Name, amount, and frequency are required'
})
}
const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN
console.log('Creating payment plan:', body.name)
const response = await fetch(`${HELCIM_API_BASE}/payment-plans`, {
method: 'POST',
headers: {
'accept': 'application/json',
'content-type': 'application/json',
'api-token': helcimToken
},
body: JSON.stringify({
planName: body.name,
planAmount: parseFloat(body.amount),
planFrequency: body.frequency, // 'monthly', 'weekly', 'biweekly', etc.
planCurrency: body.currency || 'CAD'
})
})
if (!response.ok) {
const errorText = await response.text()
console.error('Failed to create payment plan:', response.status, errorText)
throw createError({
statusCode: response.status,
statusMessage: `Failed to create payment plan: ${errorText}`
})
}
const planData = await response.json()
console.log('Payment plan created:', planData)
return {
success: true,
plan: planData
}
} catch (error) {
console.error('Error creating Helcim payment plan:', error)
throw createError({
statusCode: error.statusCode || 500,
statusMessage: error.message || 'Failed to create payment plan'
})
}
})

View file

@ -0,0 +1,83 @@
// Get customer code for an existing Helcim customer
import jwt from 'jsonwebtoken'
import Member from '../../models/member.js'
import { connectDB } from '../../utils/mongoose.js'
const HELCIM_API_BASE = 'https://api.helcim.com/v2'
export default defineEventHandler(async (event) => {
try {
await connectDB()
const config = useRuntimeConfig(event)
const token = getCookie(event, 'auth-token')
if (!token) {
throw createError({
statusCode: 401,
statusMessage: 'Not authenticated'
})
}
// Decode JWT token
let decoded
try {
decoded = jwt.verify(token, process.env.JWT_SECRET)
} catch (err) {
throw createError({
statusCode: 401,
statusMessage: 'Invalid or expired token'
})
}
// Get member
const member = await Member.findById(decoded.memberId)
if (!member) {
throw createError({
statusCode: 404,
statusMessage: 'Member not found'
})
}
if (!member.helcimCustomerId) {
throw createError({
statusCode: 400,
statusMessage: 'No Helcim customer ID found'
})
}
const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN
const response = await fetch(
`${HELCIM_API_BASE}/customers/${member.helcimCustomerId}`,
{
headers: {
'accept': 'application/json',
'api-token': helcimToken
}
}
)
if (!response.ok) {
const errorText = await response.text()
throw createError({
statusCode: response.status,
statusMessage: `Failed to get customer: ${errorText}`
})
}
const customerData = await response.json()
return {
success: true,
customerId: customerData.id,
customerCode: customerData.customerCode
}
} catch (error) {
console.error('Error getting customer code:', error)
throw createError({
statusCode: error.statusCode || 500,
statusMessage: error.message || 'Failed to get customer code'
})
}
})

View file

@ -0,0 +1,149 @@
// Create a Helcim customer
import jwt from 'jsonwebtoken'
import Member from '../../models/member.js'
import { connectDB } from '../../utils/mongoose.js'
const HELCIM_API_BASE = 'https://api.helcim.com/v2'
export default defineEventHandler(async (event) => {
try {
await connectDB()
const config = useRuntimeConfig(event)
const body = await readBody(event)
// Validate required fields
if (!body.name || !body.email) {
throw createError({
statusCode: 400,
statusMessage: 'Name and email are required'
})
}
// Check if member already exists
const existingMember = await Member.findOne({ email: body.email })
if (existingMember) {
throw createError({
statusCode: 409,
statusMessage: 'A member with this email already exists'
})
}
// Get token directly from environment if not in config
const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN
if (!helcimToken) {
throw createError({
statusCode: 500,
statusMessage: 'Helcim API token not configured'
})
}
// Debug: Log token (first few chars only)
console.log('Using Helcim token:', helcimToken.substring(0, 10) + '...')
// Test the connection first with native fetch
try {
const testResponse = await fetch('https://api.helcim.com/v2/connection-test', {
method: 'GET',
headers: {
'accept': 'application/json',
'api-token': helcimToken
}
})
if (!testResponse.ok) {
throw new Error(`HTTP ${testResponse.status}: ${testResponse.statusText}`)
}
const testData = await testResponse.json()
console.log('Connection test passed:', testData)
} catch (testError) {
console.error('Connection test failed:', testError)
throw createError({
statusCode: 401,
statusMessage: `Helcim API connection failed: ${testError.message}`
})
}
// Create customer in Helcim using native fetch
const customerResponse = await fetch(`${HELCIM_API_BASE}/customers`, {
method: 'POST',
headers: {
'accept': 'application/json',
'content-type': 'application/json',
'api-token': helcimToken
},
body: JSON.stringify({
customerType: 'PERSON',
contactName: body.name,
email: body.email
})
})
if (!customerResponse.ok) {
const errorText = await customerResponse.text()
console.error('Customer creation failed:', customerResponse.status, errorText)
throw createError({
statusCode: customerResponse.status,
statusMessage: `Failed to create customer: ${errorText}`
})
}
const customerData = await customerResponse.json()
// Create member in database
const member = await Member.create({
email: body.email,
name: body.name,
circle: body.circle,
contributionTier: body.contributionTier,
helcimCustomerId: customerData.id,
status: 'pending_payment'
})
// Generate JWT token for the session
const token = jwt.sign(
{
memberId: member._id,
email: body.email,
helcimCustomerId: customerData.id
},
process.env.JWT_SECRET,
{ expiresIn: '24h' }
)
// Set the session cookie server-side
console.log('Setting auth-token cookie for member:', member.email)
console.log('NODE_ENV:', process.env.NODE_ENV)
setCookie(event, 'auth-token', token, {
httpOnly: true, // Server-only for security
secure: false, // Don't require HTTPS in development
sameSite: 'lax',
maxAge: 60 * 60 * 24, // 24 hours
path: '/',
domain: undefined // Let browser set domain automatically
})
console.log('Cookie set successfully')
return {
success: true,
customerId: customerData.id,
customerCode: customerData.customerCode,
token,
member: {
id: member._id,
email: member.email,
name: member.name,
circle: member.circle,
contributionTier: member.contributionTier,
status: member.status
}
}
} catch (error) {
console.error('Error creating Helcim customer:', error)
throw createError({
statusCode: error.statusCode || 500,
statusMessage: error.message || 'Failed to create customer'
})
}
})

View file

@ -0,0 +1,130 @@
// Get existing or create new Helcim customer (for upgrading members)
import jwt from 'jsonwebtoken'
import Member from '../../models/member.js'
import { connectDB } from '../../utils/mongoose.js'
const HELCIM_API_BASE = 'https://api.helcim.com/v2'
export default defineEventHandler(async (event) => {
try {
await connectDB()
const config = useRuntimeConfig(event)
const token = getCookie(event, 'auth-token')
if (!token) {
throw createError({
statusCode: 401,
statusMessage: 'Not authenticated'
})
}
// Decode JWT token
let decoded
try {
decoded = jwt.verify(token, process.env.JWT_SECRET)
} catch (err) {
throw createError({
statusCode: 401,
statusMessage: 'Invalid or expired token'
})
}
// Get member
const member = await Member.findById(decoded.memberId)
if (!member) {
throw createError({
statusCode: 404,
statusMessage: 'Member not found'
})
}
const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN
// First, search for existing customer
try {
const searchResponse = await fetch(
`${HELCIM_API_BASE}/customers?search=${encodeURIComponent(member.email)}`,
{
headers: {
'accept': 'application/json',
'api-token': helcimToken
}
}
)
if (searchResponse.ok) {
const searchData = await searchResponse.json()
if (searchData.customers && searchData.customers.length > 0) {
const existingCustomer = searchData.customers.find(c => c.email === member.email)
if (existingCustomer) {
console.log('Found existing Helcim customer:', existingCustomer.id)
// Update member record with customer ID if not already set
if (!member.helcimCustomerId) {
member.helcimCustomerId = existingCustomer.id
await member.save()
}
return {
success: true,
customerId: existingCustomer.id,
customerCode: existingCustomer.customerCode,
existing: true
}
}
}
}
} catch (searchError) {
console.log('Error searching for customer:', searchError)
// Continue to create new customer
}
// No existing customer found, create new one
console.log('Creating new Helcim customer for:', member.email)
const createResponse = await fetch(`${HELCIM_API_BASE}/customers`, {
method: 'POST',
headers: {
'accept': 'application/json',
'content-type': 'application/json',
'api-token': helcimToken
},
body: JSON.stringify({
contactName: member.name,
businessName: member.name,
email: member.email
})
})
if (!createResponse.ok) {
const errorText = await createResponse.text()
console.error('Failed to create Helcim customer:', createResponse.status, errorText)
throw createError({
statusCode: createResponse.status,
statusMessage: `Failed to create Helcim customer: ${errorText}`
})
}
const customerData = await createResponse.json()
console.log('Created Helcim customer:', customerData.id)
// Update member record with customer ID
member.helcimCustomerId = customerData.id
await member.save()
return {
success: true,
customerId: customerData.id,
customerCode: customerData.customerCode,
existing: false
}
} catch (error) {
console.error('Error in get-or-create-customer:', error)
throw createError({
statusCode: error.statusCode || 500,
statusMessage: error.message || 'Failed to get or create customer'
})
}
})

View file

@ -0,0 +1,62 @@
// Initialize HelcimPay.js session
const HELCIM_API_BASE = 'https://api.helcim.com/v2'
export default defineEventHandler(async (event) => {
try {
const config = useRuntimeConfig(event)
const body = await readBody(event)
// Debug log the request body
console.log('Initialize payment request body:', body)
// Validate required fields
if (!body.customerId) {
throw createError({
statusCode: 400,
statusMessage: 'Customer ID is required'
})
}
const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN
// Initialize HelcimPay.js session
const response = await fetch(`${HELCIM_API_BASE}/helcim-pay/initialize`, {
method: 'POST',
headers: {
'accept': 'application/json',
'content-type': 'application/json',
'api-token': helcimToken
},
body: JSON.stringify({
paymentType: 'verify', // For card verification
amount: 0, // Must be exactly 0 for verification
currency: 'CAD',
customerCode: body.customerCode,
paymentMethod: 'cc'
})
})
if (!response.ok) {
const errorText = await response.text()
console.error('HelcimPay initialization failed:', response.status, errorText)
throw createError({
statusCode: response.status,
statusMessage: `Failed to initialize payment: ${errorText}`
})
}
const paymentData = await response.json()
return {
success: true,
checkoutToken: paymentData.checkoutToken,
secretToken: paymentData.secretToken
}
} catch (error) {
console.error('Error initializing HelcimPay:', error)
throw createError({
statusCode: error.statusCode || 500,
statusMessage: error.message || 'Failed to initialize payment'
})
}
})

View file

@ -0,0 +1,45 @@
// Get Helcim payment plans
const HELCIM_API_BASE = 'https://api.helcim.com/v2'
export default defineEventHandler(async (event) => {
try {
const config = useRuntimeConfig(event)
const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN
console.log('Fetching payment plans from Helcim...')
const response = await fetch(`${HELCIM_API_BASE}/payment-plans`, {
method: 'GET',
headers: {
'accept': 'application/json',
'api-token': helcimToken
}
})
if (!response.ok) {
console.error('Failed to fetch payment plans:', response.status, response.statusText)
const errorText = await response.text()
console.error('Response body:', errorText)
throw createError({
statusCode: response.status,
statusMessage: `Failed to fetch payment plans: ${errorText}`
})
}
const plansData = await response.json()
console.log('Payment plans retrieved:', JSON.stringify(plansData, null, 2))
return {
success: true,
plans: plansData
}
} catch (error) {
console.error('Error fetching Helcim payment plans:', error)
throw createError({
statusCode: error.statusCode || 500,
statusMessage: error.message || 'Failed to fetch payment plans'
})
}
})

View file

@ -0,0 +1,362 @@
// Create a Helcim subscription
import { getHelcimPlanId, requiresPayment } from '../../config/contributions.js'
import Member from '../../models/member.js'
import { connectDB } from '../../utils/mongoose.js'
import { getSlackService } from '../../utils/slack.ts'
const HELCIM_API_BASE = 'https://api.helcim.com/v2'
// Function to invite member to Slack
async function inviteToSlack(member) {
try {
const slackService = getSlackService()
if (!slackService) {
console.warn('Slack service not configured, skipping invitation')
return
}
console.log(`Processing Slack invitation for ${member.email}...`)
const inviteResult = await slackService.inviteUserToSlack(
member.email,
member.name
)
if (inviteResult.success) {
// Update member record based on the actual result
if (inviteResult.status === 'existing_user_added_to_channel' ||
inviteResult.status === 'user_already_in_channel' ||
inviteResult.status === 'new_user_invited_to_workspace') {
member.slackInviteStatus = 'sent'
member.slackUserId = inviteResult.userId
member.slackInvited = true
} else {
// Manual invitation required
member.slackInviteStatus = 'pending'
member.slackInvited = false
}
await member.save()
// Send notification to vetting channel
await slackService.notifyNewMember(
member.name,
member.email,
member.circle,
member.contributionTier,
inviteResult.status
)
console.log(`Successfully processed Slack invitation for ${member.email}: ${inviteResult.status}`)
} else {
// Update member record to reflect failed invitation
member.slackInviteStatus = 'failed'
await member.save()
console.error(`Failed to process Slack invitation for ${member.email}: ${inviteResult.error}`)
// Don't throw error - subscription creation should still succeed
}
} catch (error) {
console.error('Error during Slack invitation process:', error)
// Update member record to reflect failed invitation
try {
member.slackInviteStatus = 'failed'
await member.save()
} catch (saveError) {
console.error('Failed to update member Slack status:', saveError)
}
// Don't throw error - subscription creation should still succeed
}
}
export default defineEventHandler(async (event) => {
try {
await connectDB()
const config = useRuntimeConfig(event)
const body = await readBody(event)
// Validate required fields
if (!body.customerId || !body.contributionTier) {
throw createError({
statusCode: 400,
statusMessage: 'Customer ID and contribution tier are required'
})
}
if (!body.customerCode) {
throw createError({
statusCode: 400,
statusMessage: 'Customer code is required for subscription creation'
})
}
console.log('Subscription request body:', body)
// Check if payment is required
if (!requiresPayment(body.contributionTier)) {
console.log('No payment required for tier:', body.contributionTier)
// For free tier, just update member status
const member = await Member.findOneAndUpdate(
{ helcimCustomerId: body.customerId },
{
status: 'active',
contributionTier: body.contributionTier,
subscriptionStartDate: new Date()
},
{ new: true }
)
console.log('Updated member for free tier:', member)
// Send Slack invitation for free tier members
await inviteToSlack(member)
return {
success: true,
subscription: null,
member
}
}
console.log('Payment required for tier:', body.contributionTier)
// Get the Helcim plan ID
const planId = getHelcimPlanId(body.contributionTier)
console.log('Plan ID for tier:', planId)
// Validate card token is provided
if (!body.cardToken) {
throw createError({
statusCode: 400,
statusMessage: 'Payment information is required for this contribution tier'
})
}
// Check if we have a configured plan for this tier
if (!planId) {
console.log('No Helcim plan configured for tier:', body.contributionTier)
const member = await Member.findOneAndUpdate(
{ helcimCustomerId: body.customerId },
{
status: 'active',
contributionTier: body.contributionTier,
subscriptionStartDate: new Date(),
paymentMethod: 'card',
cardToken: body.cardToken,
notes: `Payment successful but no Helcim plan configured for tier ${body.contributionTier}`
},
{ new: true }
)
// Send Slack invitation even when no plan is configured
await inviteToSlack(member)
return {
success: true,
subscription: {
subscriptionId: 'manual-' + Date.now(),
status: 'needs_plan_setup',
nextBillingDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)
},
member,
warning: `Payment successful but recurring plan needs to be set up in Helcim for the ${body.contributionTier} tier`
}
}
// Try to create subscription in Helcim
const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN
console.log('Attempting to create Helcim subscription with plan ID:', planId)
// Generate a proper alphanumeric idempotency key (exactly 25 characters)
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
let idempotencyKey = ''
for (let i = 0; i < 25; i++) {
idempotencyKey += chars.charAt(Math.floor(Math.random() * chars.length))
}
// Get contribution tier details to set recurring amount
const { getContributionTierByValue } = await import('../../config/contributions.js')
const tierInfo = getContributionTierByValue(body.contributionTier)
const requestBody = {
subscriptions: [{
dateActivated: new Date().toISOString().split('T')[0], // Today in YYYY-MM-DD format
paymentPlanId: parseInt(planId),
customerCode: body.customerCode,
recurringAmount: parseFloat(tierInfo.amount),
paymentMethod: 'card'
}]
}
const requestHeaders = {
'accept': 'application/json',
'content-type': 'application/json',
'api-token': helcimToken,
'idempotency-key': idempotencyKey
}
console.log('Subscription request body:', requestBody)
console.log('Request headers:', requestHeaders)
console.log('Request URL:', `${HELCIM_API_BASE}/subscriptions`)
try {
const subscriptionResponse = await fetch(`${HELCIM_API_BASE}/subscriptions`, {
method: 'POST',
headers: requestHeaders,
body: JSON.stringify(requestBody)
})
if (!subscriptionResponse.ok) {
const errorText = await subscriptionResponse.text()
console.error('Subscription creation failed:')
console.error('Status:', subscriptionResponse.status)
console.error('Status Text:', subscriptionResponse.statusText)
console.error('Headers:', Object.fromEntries(subscriptionResponse.headers.entries()))
console.error('Response Body:', errorText)
console.error('Request was:', {
url: `${HELCIM_API_BASE}/subscriptions`,
method: 'POST',
body: requestBody,
headers: {
'accept': 'application/json',
'content-type': 'application/json',
'api-token': helcimToken ? 'present' : 'missing'
}
})
// If it's a validation error, let's try to get more info about available plans
if (subscriptionResponse.status === 400 || subscriptionResponse.status === 404) {
console.log('Plan might not exist. Trying to get list of available payment plans...')
// Try to fetch available payment plans
try {
const plansResponse = await fetch(`${HELCIM_API_BASE}/payment-plans`, {
method: 'GET',
headers: {
'accept': 'application/json',
'api-token': helcimToken
}
})
if (plansResponse.ok) {
const plansData = await plansResponse.json()
console.log('Available payment plans:', JSON.stringify(plansData, null, 2))
} else {
console.log('Could not fetch payment plans:', plansResponse.status, plansResponse.statusText)
}
} catch (planError) {
console.log('Error fetching payment plans:', planError.message)
}
// For now, just update member status and let user know we need to configure plans
const member = await Member.findOneAndUpdate(
{ helcimCustomerId: body.customerId },
{
status: 'active',
contributionTier: body.contributionTier,
subscriptionStartDate: new Date(),
paymentMethod: 'card',
cardToken: body.cardToken,
notes: `Payment successful but subscription creation failed: ${errorText}`
},
{ new: true }
)
// Send Slack invitation even when subscription setup fails
await inviteToSlack(member)
return {
success: true,
subscription: {
subscriptionId: 'manual-' + Date.now(),
status: 'needs_setup',
error: errorText,
nextBillingDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)
},
member,
warning: 'Payment successful but recurring subscription needs manual setup'
}
}
throw createError({
statusCode: subscriptionResponse.status,
statusMessage: `Failed to create subscription: ${errorText}`
})
}
const subscriptionData = await subscriptionResponse.json()
console.log('Subscription created successfully:', subscriptionData)
// Extract the first subscription from the response array
const subscription = subscriptionData.data?.[0]
if (!subscription) {
throw new Error('No subscription returned in response')
}
// Update member in database
const member = await Member.findOneAndUpdate(
{ helcimCustomerId: body.customerId },
{
status: 'active',
contributionTier: body.contributionTier,
helcimSubscriptionId: subscription.id,
subscriptionStartDate: new Date(),
paymentMethod: 'card'
},
{ new: true }
)
// Send Slack invitation for paid tier members
await inviteToSlack(member)
return {
success: true,
subscription: {
subscriptionId: subscription.id,
status: subscription.status,
nextBillingDate: subscription.nextBillingDate
},
member
}
} catch (fetchError) {
console.error('Error during subscription creation:', fetchError)
// Still mark member as active since payment was successful
const member = await Member.findOneAndUpdate(
{ helcimCustomerId: body.customerId },
{
status: 'active',
contributionTier: body.contributionTier,
subscriptionStartDate: new Date(),
paymentMethod: 'card',
cardToken: body.cardToken,
notes: `Payment successful but subscription creation failed: ${fetchError.message}`
},
{ new: true }
)
// Send Slack invitation even when subscription fetch fails
await inviteToSlack(member)
return {
success: true,
subscription: {
subscriptionId: 'manual-' + Date.now(),
status: 'needs_setup',
error: fetchError.message,
nextBillingDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)
},
member,
warning: 'Payment successful but recurring subscription needs manual setup'
}
}
} catch (error) {
console.error('Error creating Helcim subscription:', error)
throw createError({
statusCode: error.statusCode || 500,
statusMessage: error.message || 'Failed to create subscription'
})
}
})

View file

@ -0,0 +1,45 @@
// Get existing Helcim subscriptions to understand the format
const HELCIM_API_BASE = 'https://api.helcim.com/v2'
export default defineEventHandler(async (event) => {
try {
const config = useRuntimeConfig(event)
const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN
console.log('Fetching existing subscriptions from Helcim...')
const response = await fetch(`${HELCIM_API_BASE}/subscriptions`, {
method: 'GET',
headers: {
'accept': 'application/json',
'api-token': helcimToken
}
})
if (!response.ok) {
console.error('Failed to fetch subscriptions:', response.status, response.statusText)
const errorText = await response.text()
console.error('Response body:', errorText)
throw createError({
statusCode: response.status,
statusMessage: `Failed to fetch subscriptions: ${errorText}`
})
}
const subscriptionsData = await response.json()
console.log('Existing subscriptions:', JSON.stringify(subscriptionsData, null, 2))
return {
success: true,
subscriptions: subscriptionsData
}
} catch (error) {
console.error('Error fetching Helcim subscriptions:', error)
throw createError({
statusCode: error.statusCode || 500,
statusMessage: error.message || 'Failed to fetch subscriptions'
})
}
})

View file

@ -0,0 +1,46 @@
// Test Helcim API connection
const HELCIM_API_BASE = 'https://api.helcim.com/v2'
export default defineEventHandler(async (event) => {
try {
const config = useRuntimeConfig(event)
// Log token info (safely)
const tokenInfo = {
hasToken: !!config.public.helcimToken,
tokenLength: config.public.helcimToken ? config.public.helcimToken.length : 0,
tokenPrefix: config.public.helcimToken ? config.public.helcimToken.substring(0, 10) : null
}
console.log('Helcim Token Info:', tokenInfo)
const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN
// Try connection test endpoint
const response = await $fetch(`${HELCIM_API_BASE}/connection-test`, {
method: 'GET',
headers: {
'accept': 'application/json',
'api-token': helcimToken
}
})
return {
success: true,
message: 'Helcim API connection successful',
tokenInfo,
connectionResponse: response
}
} catch (error) {
console.error('Helcim test error:', error)
return {
success: false,
message: error.message || 'Failed to connect to Helcim API',
statusCode: error.statusCode,
tokenInfo: {
hasToken: !!useRuntimeConfig().public.helcimToken,
tokenLength: useRuntimeConfig().public.helcimToken ? useRuntimeConfig().public.helcimToken.length : 0
}
}
}
})

View file

@ -0,0 +1,77 @@
// Test minimal subscription creation to understand required fields
const HELCIM_API_BASE = 'https://api.helcim.com/v2'
export default defineEventHandler(async (event) => {
try {
const config = useRuntimeConfig(event)
const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN
// Generate a 25-character idempotency key
const idempotencyKey = `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`.substring(0, 25)
// Test with minimal fields first
const testRequest1 = {
customerCode: 'CST1020', // Use a recent customer code
planId: 20162
}
console.log('Testing subscription with minimal fields:', testRequest1)
try {
const response1 = await fetch(`${HELCIM_API_BASE}/subscriptions`, {
method: 'POST',
headers: {
'accept': 'application/json',
'content-type': 'application/json',
'api-token': helcimToken,
'idempotency-key': idempotencyKey + 'a'
},
body: JSON.stringify(testRequest1)
})
const result1 = await response1.text()
console.log('Test 1 - Status:', response1.status)
console.log('Test 1 - Response:', result1)
if (!response1.ok) {
// Try with paymentPlanId instead
const testRequest2 = {
customerCode: 'CST1020',
paymentPlanId: 20162
}
console.log('Testing subscription with paymentPlanId:', testRequest2)
const response2 = await fetch(`${HELCIM_API_BASE}/subscriptions`, {
method: 'POST',
headers: {
'accept': 'application/json',
'content-type': 'application/json',
'api-token': helcimToken,
'idempotency-key': idempotencyKey + 'b'
},
body: JSON.stringify(testRequest2)
})
const result2 = await response2.text()
console.log('Test 2 - Status:', response2.status)
console.log('Test 2 - Response:', result2)
}
} catch (error) {
console.error('Test error:', error)
}
return {
success: true,
message: 'Check server logs for test results'
}
} catch (error) {
console.error('Error in test endpoint:', error)
throw createError({
statusCode: 500,
statusMessage: error.message
})
}
})

View file

@ -0,0 +1,71 @@
// Update customer billing address
const HELCIM_API_BASE = 'https://api.helcim.com/v2'
export default defineEventHandler(async (event) => {
try {
const config = useRuntimeConfig(event)
const body = await readBody(event)
// Validate required fields
if (!body.customerId || !body.billingAddress) {
throw createError({
statusCode: 400,
statusMessage: 'Customer ID and billing address are required'
})
}
const { billingAddress } = body
// Validate billing address fields
if (!billingAddress.street || !billingAddress.city || !billingAddress.country || !billingAddress.postalCode) {
throw createError({
statusCode: 400,
statusMessage: 'Complete billing address is required'
})
}
const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN
// Update customer billing address in Helcim
const response = await fetch(`${HELCIM_API_BASE}/customers/${body.customerId}`, {
method: 'PATCH',
headers: {
'accept': 'application/json',
'content-type': 'application/json',
'api-token': helcimToken
},
body: JSON.stringify({
billingAddress: {
name: billingAddress.name,
street1: billingAddress.street,
city: billingAddress.city,
province: billingAddress.province || billingAddress.state,
country: billingAddress.country,
postalCode: billingAddress.postalCode
}
})
})
if (!response.ok) {
const errorText = await response.text()
console.error('Billing address update failed:', response.status, errorText)
throw createError({
statusCode: response.status,
statusMessage: `Failed to update billing address: ${errorText}`
})
}
const customerData = await response.json()
return {
success: true,
customer: customerData
}
} catch (error) {
console.error('Error updating billing address:', error)
throw createError({
statusCode: error.statusCode || 500,
statusMessage: error.message || 'Failed to update billing address'
})
}
})

View file

@ -0,0 +1,38 @@
// Verify payment token from HelcimPay.js
const HELCIM_API_BASE = 'https://api.helcim.com/v2'
export default defineEventHandler(async (event) => {
try {
const config = useRuntimeConfig(event)
const body = await readBody(event)
// Validate required fields
if (!body.cardToken || !body.customerId) {
throw createError({
statusCode: 400,
statusMessage: 'Card token and customer ID are required'
})
}
console.log('Payment verification request:', {
customerId: body.customerId,
cardToken: body.cardToken ? 'present' : 'missing'
})
// Since HelcimPay.js already verified the payment and we have the card token,
// we can just return success. The card is already associated with the customer.
console.log('Payment already verified through HelcimPay.js, returning success')
return {
success: true,
cardToken: body.cardToken,
message: 'Payment verified successfully through HelcimPay.js'
}
} catch (error) {
console.error('Error verifying payment:', error)
throw createError({
statusCode: error.statusCode || 500,
statusMessage: error.message || 'Failed to verify payment'
})
}
})

View file

@ -0,0 +1,100 @@
// Cancel member subscription
import jwt from "jsonwebtoken";
import Member from "../../models/member.js";
import { connectDB } from "../../utils/mongoose.js";
const HELCIM_API_BASE = "https://api.helcim.com/v2";
export default defineEventHandler(async (event) => {
try {
await connectDB();
const config = useRuntimeConfig(event);
const token = getCookie(event, "auth-token");
if (!token) {
throw createError({
statusCode: 401,
statusMessage: "Not authenticated",
});
}
// Decode JWT token
let decoded;
try {
decoded = jwt.verify(token, process.env.JWT_SECRET);
} catch (err) {
throw createError({
statusCode: 401,
statusMessage: "Invalid or expired token",
});
}
// Get member
const member = await Member.findById(decoded.memberId);
if (!member) {
throw createError({
statusCode: 404,
statusMessage: "Member not found",
});
}
// If already on free tier, nothing to cancel
if (member.contributionTier === "0" || !member.helcimSubscriptionId) {
return {
success: true,
message: "No active subscription to cancel",
member,
};
}
const helcimToken =
config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN;
try {
// Cancel Helcim subscription
const response = await fetch(
`${HELCIM_API_BASE}/subscriptions/${member.helcimSubscriptionId}`,
{
method: "DELETE",
headers: {
accept: "application/json",
"api-token": helcimToken,
},
},
);
if (!response.ok) {
const errorText = await response.text();
console.error(
"Failed to cancel Helcim subscription:",
response.status,
errorText,
);
// Continue anyway - we'll update the member record
}
} catch (error) {
console.error("Error canceling Helcim subscription:", error);
// Continue anyway - we'll update the member record
}
// Update member status
member.status = "cancelled";
member.contributionTier = "0";
member.helcimSubscriptionId = null;
member.paymentMethod = "none";
member.subscriptionEndDate = new Date();
await member.save();
return {
success: true,
message: "Subscription cancelled successfully",
member,
};
} catch (error) {
console.error("Error cancelling subscription:", error);
throw createError({
statusCode: error.statusCode || 500,
statusMessage: error.message || "Failed to cancel subscription",
});
}
});

View file

@ -1,9 +1,74 @@
// server/api/members/create.post.js // server/api/members/create.post.js
import Member from '../../models/member.js' import Member from '../../models/member.js'
import { connectDB } from '../../utils/mongoose.js' import { connectDB } from '../../utils/mongoose.js'
import { getSlackService } from '../../utils/slack.ts'
// Simple payment check function to avoid import issues // Simple payment check function to avoid import issues
const requiresPayment = (contributionValue) => contributionValue !== '0' const requiresPayment = (contributionValue) => contributionValue !== '0'
// Function to invite member to Slack
async function inviteToSlack(member) {
try {
const slackService = getSlackService()
if (!slackService) {
console.warn('Slack service not configured, skipping invitation')
return
}
console.log(`Processing Slack invitation for ${member.email}...`)
const inviteResult = await slackService.inviteUserToSlack(
member.email,
member.name
)
if (inviteResult.success) {
// Update member record based on the actual result
if (inviteResult.status === 'existing_user_added_to_channel' ||
inviteResult.status === 'user_already_in_channel' ||
inviteResult.status === 'new_user_invited_to_workspace') {
member.slackInviteStatus = 'sent'
member.slackUserId = inviteResult.userId
member.slackInvited = true
} else {
// Manual invitation required
member.slackInviteStatus = 'pending'
member.slackInvited = false
}
await member.save()
// Send notification to vetting channel
await slackService.notifyNewMember(
member.name,
member.email,
member.circle,
member.contributionTier,
inviteResult.status
)
console.log(`Successfully processed Slack invitation for ${member.email}: ${inviteResult.status}`)
} else {
// Update member record to reflect failed invitation
member.slackInviteStatus = 'failed'
await member.save()
console.error(`Failed to process Slack invitation for ${member.email}: ${inviteResult.error}`)
// Don't throw error - member creation should still succeed
}
} catch (error) {
console.error('Error during Slack invitation process:', error)
// Update member record to reflect failed invitation
try {
member.slackInviteStatus = 'failed'
await member.save()
} catch (saveError) {
console.error('Failed to update member Slack status:', saveError)
}
// Don't throw error - member creation should still succeed
}
}
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
// Ensure database is connected // Ensure database is connected
await connectDB() await connectDB()
@ -23,6 +88,9 @@ export default defineEventHandler(async (event) => {
const member = new Member(body) const member = new Member(body)
await member.save() await member.save()
// Send Slack invitation for new members
await inviteToSlack(member)
// TODO: Process payment with Helcim if not free tier // TODO: Process payment with Helcim if not free tier
if (requiresPayment(body.contributionTier)) { if (requiresPayment(body.contributionTier)) {
// Payment processing will be added here // Payment processing will be added here

View file

@ -0,0 +1,115 @@
import jwt from "jsonwebtoken";
import Member from "../../models/member.js";
import { connectDB } from "../../utils/mongoose.js";
export default defineEventHandler(async (event) => {
await connectDB();
// Check if user is authenticated
const token = getCookie(event, "auth-token");
let isAuthenticated = false;
let currentMemberId = null;
if (token) {
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
currentMemberId = decoded.memberId;
isAuthenticated = true;
} catch (err) {
// Invalid token, treat as public
isAuthenticated = false;
}
}
const query = getQuery(event);
const search = query.search || "";
const circle = query.circle || "";
const skills = query.skills ? query.skills.split(",") : [];
// Build query
const dbQuery = {
showInDirectory: true,
status: "active",
};
// Filter by circle if specified
if (circle) {
dbQuery.circle = circle;
}
// Search by name or bio
if (search) {
dbQuery.$or = [
{ name: { $regex: search, $options: "i" } },
{ bio: { $regex: search, $options: "i" } },
];
}
// Filter by skills
if (skills.length > 0) {
dbQuery.skills = { $in: skills };
}
try {
const members = await Member.find(dbQuery)
.select(
"name pronouns timeZone avatar studio bio skills location socialLinks offering lookingFor privacy circle createdAt"
)
.sort({ createdAt: -1 })
.lean();
// Filter fields based on privacy settings
const filteredMembers = members.map((member) => {
const privacy = member.privacy || {};
const filtered = {
_id: member._id,
name: member.name,
circle: member.circle,
createdAt: member.createdAt,
};
// Helper function to check if field should be visible
const isVisible = (field) => {
const privacySetting = privacy[field] || "members";
if (privacySetting === "public") return true;
if (privacySetting === "members" && isAuthenticated) return true;
if (privacySetting === "private") return false;
return false;
};
// Add fields based on privacy settings
if (isVisible("avatar")) filtered.avatar = member.avatar;
if (isVisible("pronouns")) filtered.pronouns = member.pronouns;
if (isVisible("timeZone")) filtered.timeZone = member.timeZone;
if (isVisible("studio")) filtered.studio = member.studio;
if (isVisible("bio")) filtered.bio = member.bio;
if (isVisible("skills")) filtered.skills = member.skills;
if (isVisible("location")) filtered.location = member.location;
if (isVisible("socialLinks")) filtered.socialLinks = member.socialLinks;
if (isVisible("offering")) filtered.offering = member.offering;
if (isVisible("lookingFor")) filtered.lookingFor = member.lookingFor;
return filtered;
});
// Get unique skills for filter options
const allSkills = members
.flatMap((m) => m.skills || [])
.filter((skill, index, self) => self.indexOf(skill) === index)
.sort();
return {
members: filteredMembers,
totalCount: filteredMembers.length,
filters: {
availableSkills: allSkills,
},
};
} catch (error) {
console.error("Directory fetch error:", error);
throw createError({
statusCode: 500,
message: "Failed to fetch member directory",
});
}
});

View file

@ -0,0 +1,60 @@
import Event from "../../models/event";
import Member from "../../models/member";
export default defineEventHandler(async (event) => {
const query = getQuery(event);
const { memberId } = query;
if (!memberId) {
throw createError({
statusCode: 400,
statusMessage: "Member ID is required",
});
}
try {
// Verify member exists
const member = await Member.findById(memberId);
if (!member) {
throw createError({
statusCode: 404,
statusMessage: "Member not found",
});
}
// Find all events where the user is registered
// Filter out cancelled events and only show future events
const now = new Date();
const events = await Event.find({
"registrations.memberId": memberId,
isCancelled: { $ne: true },
startDate: { $gte: now },
})
.select(
"title slug description startDate endDate location featureImage maxAttendees registeredCount",
)
.sort({ startDate: 1 })
.limit(10);
console.log(
`Found ${events.length} registered events for member ${memberId}`,
);
return {
events,
count: events.length,
};
} catch (error) {
console.error("Error fetching member events:", error);
if (error.statusCode) {
throw error;
}
throw createError({
statusCode: 500,
statusMessage: "Failed to fetch registered events",
});
}
});

View file

@ -0,0 +1,117 @@
import jwt from "jsonwebtoken";
import Member from "../../models/member.js";
import { connectDB } from "../../utils/mongoose.js";
export default defineEventHandler(async (event) => {
await connectDB();
const token = getCookie(event, "auth-token");
if (!token) {
throw createError({
statusCode: 401,
statusMessage: "Not authenticated",
});
}
let memberId;
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
memberId = decoded.memberId;
} catch (err) {
throw createError({
statusCode: 401,
statusMessage: "Invalid or expired token",
});
}
const body = await readBody(event);
// Define allowed profile fields
const allowedFields = [
"pronouns",
"timeZone",
"avatar",
"studio",
"bio",
"skills",
"location",
"socialLinks",
"offering",
"lookingFor",
"showInDirectory",
"helcimCustomerId",
];
// Define privacy fields
const privacyFields = [
"pronounsPrivacy",
"timeZonePrivacy",
"avatarPrivacy",
"studioPrivacy",
"bioPrivacy",
"skillsPrivacy",
"locationPrivacy",
"socialLinksPrivacy",
"offeringPrivacy",
"lookingForPrivacy",
];
// Build update object
const updateData = {};
allowedFields.forEach((field) => {
if (body[field] !== undefined) {
updateData[field] = body[field];
}
});
// Handle privacy settings
privacyFields.forEach((privacyField) => {
if (body[privacyField] !== undefined) {
const baseField = privacyField.replace("Privacy", "");
updateData[`privacy.${baseField}`] = body[privacyField];
}
});
try {
const member = await Member.findByIdAndUpdate(
memberId,
{ $set: updateData },
{ new: true, runValidators: true },
);
if (!member) {
throw createError({
statusCode: 404,
message: "Member not found",
});
}
// Return sanitized member data
return {
id: member._id,
email: member.email,
name: member.name,
circle: member.circle,
contributionTier: member.contributionTier,
pronouns: member.pronouns,
timeZone: member.timeZone,
avatar: member.avatar,
studio: member.studio,
bio: member.bio,
skills: member.skills,
location: member.location,
socialLinks: member.socialLinks,
offering: member.offering,
lookingFor: member.lookingFor,
showInDirectory: member.showInDirectory,
};
} catch (error) {
console.error("Profile update error:", error);
throw createError({
statusCode: 500,
message: "Failed to update profile",
});
}
});

View file

@ -0,0 +1,354 @@
// Update member's contribution tier
import jwt from "jsonwebtoken";
import {
getHelcimPlanId,
requiresPayment,
isValidContributionValue,
} from "../../config/contributions.js";
import Member from "../../models/member.js";
import { connectDB } from "../../utils/mongoose.js";
const HELCIM_API_BASE = "https://api.helcim.com/v2";
export default defineEventHandler(async (event) => {
try {
await connectDB();
const config = useRuntimeConfig(event);
const body = await readBody(event);
const token = getCookie(event, "auth-token");
if (!token) {
throw createError({
statusCode: 401,
statusMessage: "Not authenticated",
});
}
// Decode JWT token
let decoded;
try {
decoded = jwt.verify(token, process.env.JWT_SECRET);
} catch (err) {
throw createError({
statusCode: 401,
statusMessage: "Invalid or expired token",
});
}
// Validate contribution tier
if (
!body.contributionTier ||
!isValidContributionValue(body.contributionTier)
) {
throw createError({
statusCode: 400,
statusMessage: "Invalid contribution tier",
});
}
// Get member
const member = await Member.findById(decoded.memberId);
if (!member) {
throw createError({
statusCode: 404,
statusMessage: "Member not found",
});
}
const oldTier = member.contributionTier;
const newTier = body.contributionTier;
// If same tier, nothing to do
if (oldTier === newTier) {
return {
success: true,
message: "Already on this tier",
member,
};
}
const helcimToken =
config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN;
const oldRequiresPayment = requiresPayment(oldTier);
const newRequiresPayment = requiresPayment(newTier);
// Case 1: Moving from free to paid tier
if (!oldRequiresPayment && newRequiresPayment) {
// Check if member has Helcim customer ID with saved payment method
if (!member.helcimCustomerId) {
throw createError({
statusCode: 400,
statusMessage:
"Please use the subscription creation flow to upgrade to a paid tier",
data: { requiresPaymentSetup: true },
});
}
// Try to fetch customer info from Helcim to check for saved cards
const helcimToken =
config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN;
try {
const customerResponse = await fetch(
`${HELCIM_API_BASE}/customers/${member.helcimCustomerId}`,
{
method: "GET",
headers: {
accept: "application/json",
"api-token": helcimToken,
},
},
);
if (!customerResponse.ok) {
throw new Error("Failed to fetch customer info");
}
const customerData = await customerResponse.json();
const customerCode = customerData.customerCode;
if (!customerCode) {
throw new Error("No customer code found");
}
// Check if customer has saved cards
const cardsResponse = await fetch(
`${HELCIM_API_BASE}/card-terminals?customerId=${member.helcimCustomerId}`,
{
method: "GET",
headers: {
accept: "application/json",
"api-token": helcimToken,
},
},
);
let hasCards = false;
if (cardsResponse.ok) {
const cardsData = await cardsResponse.json();
hasCards = cardsData.cards && cardsData.cards.length > 0;
}
if (!hasCards) {
throw new Error("No saved payment methods");
}
// Create new subscription with saved payment method
const newPlanId = getHelcimPlanId(newTier);
if (!newPlanId) {
throw createError({
statusCode: 400,
statusMessage: `Plan not configured for tier ${newTier}`,
});
}
// Generate idempotency key
const chars =
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let idempotencyKey = "";
for (let i = 0; i < 25; i++) {
idempotencyKey += chars.charAt(
Math.floor(Math.random() * chars.length),
);
}
// Get tier amount
const { getContributionTierByValue } = await import(
"../../config/contributions.js"
);
const tierInfo = getContributionTierByValue(newTier);
const subscriptionResponse = await fetch(
`${HELCIM_API_BASE}/subscriptions`,
{
method: "POST",
headers: {
accept: "application/json",
"content-type": "application/json",
"api-token": helcimToken,
"idempotency-key": idempotencyKey,
},
body: JSON.stringify({
subscriptions: [
{
dateActivated: new Date().toISOString().split("T")[0],
paymentPlanId: parseInt(newPlanId),
customerCode: customerCode,
recurringAmount: parseFloat(tierInfo.amount),
paymentMethod: "card",
},
],
}),
},
);
if (!subscriptionResponse.ok) {
const errorText = await subscriptionResponse.text();
console.error("Failed to create subscription:", errorText);
throw new Error(`Failed to create subscription: ${errorText}`);
}
const subscriptionData = await subscriptionResponse.json();
const subscription = subscriptionData.data?.[0];
if (!subscription) {
throw new Error("No subscription returned in response");
}
// Update member record
member.contributionTier = newTier;
member.helcimSubscriptionId = subscription.id;
member.paymentMethod = "card";
member.status = "active";
await member.save();
return {
success: true,
message: "Successfully upgraded to paid tier",
member,
subscription: {
subscriptionId: subscription.id,
status: subscription.status,
nextBillingDate: subscription.nextBillingDate,
},
};
} catch (error) {
console.error("Error creating subscription with saved payment:", error);
// If we can't use saved payment, require new payment setup
throw createError({
statusCode: 400,
statusMessage:
"Payment information required. You'll be redirected to complete payment setup.",
data: { requiresPaymentSetup: true },
});
}
}
// Case 2: Moving from paid to free tier (cancel subscription)
if (oldRequiresPayment && !newRequiresPayment) {
if (member.helcimSubscriptionId) {
try {
// Cancel Helcim subscription
const response = await fetch(
`${HELCIM_API_BASE}/subscriptions/${member.helcimSubscriptionId}`,
{
method: "DELETE",
headers: {
accept: "application/json",
"api-token": helcimToken,
},
},
);
if (!response.ok) {
console.error(
"Failed to cancel Helcim subscription:",
response.status,
);
}
} catch (error) {
console.error("Error canceling Helcim subscription:", error);
// Continue anyway - we'll update the member record
}
}
// Update member to free tier
member.contributionTier = newTier;
member.helcimSubscriptionId = null;
member.paymentMethod = "none";
await member.save();
return {
success: true,
message: "Successfully downgraded to free tier",
member,
};
}
// Case 3: Moving between paid tiers
if (oldRequiresPayment && newRequiresPayment) {
const newPlanId = getHelcimPlanId(newTier);
if (!newPlanId) {
throw createError({
statusCode: 400,
statusMessage: `Plan not configured for tier ${newTier}`,
});
}
if (!member.helcimSubscriptionId) {
// No subscription exists - they need to go through payment flow
throw createError({
statusCode: 400,
statusMessage:
"Payment information required. You'll be redirected to complete payment setup.",
data: { requiresPaymentSetup: true },
});
}
try {
// Update subscription plan in Helcim
const response = await fetch(
`${HELCIM_API_BASE}/subscriptions/${member.helcimSubscriptionId}`,
{
method: "PATCH",
headers: {
accept: "application/json",
"content-type": "application/json",
"api-token": helcimToken,
},
body: JSON.stringify({
paymentPlanId: parseInt(newPlanId),
}),
},
);
if (!response.ok) {
const errorText = await response.text();
console.error(
"Failed to update Helcim subscription:",
response.status,
errorText,
);
throw new Error(`Failed to update subscription: ${errorText}`);
}
const subscriptionData = await response.json();
// Update member record
member.contributionTier = newTier;
await member.save();
return {
success: true,
message: "Successfully updated contribution level",
member,
subscription: subscriptionData,
};
} catch (error) {
console.error("Error updating Helcim subscription:", error);
throw createError({
statusCode: 500,
statusMessage: error.message || "Failed to update subscription",
});
}
}
// Case 4: Moving between free tiers (shouldn't happen but handle it)
member.contributionTier = newTier;
await member.save();
return {
success: true,
message: "Successfully updated contribution level",
member,
};
} catch (error) {
console.error("Error updating contribution tier:", error);
throw createError({
statusCode: error.statusCode || 500,
statusMessage: error.message || "Failed to update contribution tier",
});
}
});

View file

@ -0,0 +1,78 @@
import Event from '../../models/event.js'
import { connectDB } from '../../utils/mongoose.js'
export default defineEventHandler(async (event) => {
try {
await connectDB()
const id = getRouterParam(event, 'id')
if (!id) {
throw createError({
statusCode: 400,
statusMessage: 'Series ID is required'
})
}
// Fetch all events in this series
const events = await Event.find({
'series.id': id,
'series.isSeriesEvent': true
})
.sort({ 'series.position': 1, startDate: 1 })
.select('-registrations')
.lean()
if (events.length === 0) {
throw createError({
statusCode: 404,
statusMessage: 'Event series not found'
})
}
// Get series metadata from the first event
const seriesInfo = events[0].series
// Calculate series statistics
const now = new Date()
const completedEvents = events.filter(e => e.endDate < now).length
const upcomingEvents = events.filter(e => e.startDate > now).length
const ongoingEvents = events.filter(e => e.startDate <= now && e.endDate >= now).length
const firstEventDate = events[0].startDate
const lastEventDate = events[events.length - 1].endDate
// Return series with additional metadata
return {
id: id,
title: seriesInfo.title,
description: seriesInfo.description,
type: seriesInfo.type,
totalEvents: seriesInfo.totalEvents,
startDate: firstEventDate,
endDate: lastEventDate,
events: events.map(e => ({
...e,
id: e._id.toString()
})),
statistics: {
totalEvents: events.length,
completedEvents,
upcomingEvents,
ongoingEvents,
isOngoing: firstEventDate <= now && lastEventDate >= now,
isUpcoming: firstEventDate > now,
isCompleted: lastEventDate < now,
totalRegistrations: events.reduce((sum, e) => sum + (e.registrations?.length || 0), 0)
}
}
} catch (error) {
console.error('Error fetching event series:', error)
if (error.statusCode) throw error
throw createError({
statusCode: 500,
statusMessage: 'Failed to fetch event series'
})
}
})

View file

@ -0,0 +1,91 @@
import Event from '../../models/event.js'
import { connectDB } from '../../utils/mongoose.js'
export default defineEventHandler(async (event) => {
try {
await connectDB()
const query = getQuery(event)
// Build filter for series events only
const filter = {
'series.isSeriesEvent': true,
isVisible: query.includeHidden === 'true' ? { $exists: true } : true
}
// Filter by series type
if (query.seriesType) {
filter['series.type'] = query.seriesType
}
// Filter for upcoming series
if (query.upcoming === 'true') {
filter.startDate = { $gte: new Date() }
}
// Fetch all series events and group them by series.id
const events = await Event.find(filter)
.sort({ 'series.id': 1, 'series.position': 1, startDate: 1 })
.select('-registrations')
.lean()
// Group events by series ID
const seriesMap = new Map()
events.forEach(event => {
const seriesId = event.series?.id
if (!seriesId) return
if (!seriesMap.has(seriesId)) {
seriesMap.set(seriesId, {
id: seriesId,
title: event.series.title,
description: event.series.description,
type: event.series.type,
totalEvents: event.series.totalEvents,
events: [],
firstEventDate: event.startDate,
lastEventDate: event.endDate
})
}
const series = seriesMap.get(seriesId)
series.events.push({
...event,
id: event._id.toString()
})
// Update date range
if (event.startDate < series.firstEventDate) {
series.firstEventDate = event.startDate
}
if (event.endDate > series.lastEventDate) {
series.lastEventDate = event.endDate
}
})
// Convert to array and add computed fields
const seriesArray = Array.from(seriesMap.values()).map(series => {
const now = new Date()
return {
...series,
eventCount: series.events.length,
startDate: series.firstEventDate,
endDate: series.lastEventDate,
isOngoing: series.firstEventDate <= now && series.lastEventDate >= now,
isUpcoming: series.firstEventDate > now,
isCompleted: series.lastEventDate < now,
status: series.lastEventDate < now ? 'completed' :
series.firstEventDate <= now && series.lastEventDate >= now ? 'active' : 'upcoming'
}
})
return seriesArray
} catch (error) {
console.error('Error fetching event series:', error)
throw createError({
statusCode: 500,
statusMessage: 'Failed to fetch event series'
})
}
})

View file

@ -0,0 +1,68 @@
import { WebClient } from '@slack/web-api'
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
if (!config.slackBotToken) {
return {
success: false,
error: 'Slack bot token not configured'
}
}
const client = new WebClient(config.slackBotToken)
try {
// Test basic API access
const authTest = await client.auth.test()
console.log('Auth test result:', authTest)
// Test if admin API is available
let adminApiAvailable = false
let adminError = null
try {
// Try to call admin.users.list to test admin API access
await client.admin.users.list({ limit: 1 })
adminApiAvailable = true
} catch (error: any) {
adminError = error.data?.error || error.message
console.log('Admin API test failed:', adminError)
}
// Test channel access if channel ID is configured
let channelAccess = false
let channelError = null
if (config.slackVettingChannelId) {
try {
const channelInfo = await client.conversations.info({
channel: config.slackVettingChannelId
})
channelAccess = !!channelInfo.channel
} catch (error: any) {
channelError = error.data?.error || error.message
}
}
return {
success: true,
botInfo: {
user: authTest.user,
team: authTest.team,
url: authTest.url
},
adminApiAvailable,
adminError: adminApiAvailable ? null : adminError,
channelAccess,
channelError: channelAccess ? null : channelError,
channelId: config.slackVettingChannelId || 'Not configured'
}
} catch (error: any) {
return {
success: false,
error: error.data?.error || error.message || 'Unknown error'
}
}
})

View file

@ -0,0 +1,59 @@
import jwt from "jsonwebtoken";
import Update from "../../models/update.js";
import { connectDB } from "../../utils/mongoose.js";
export default defineEventHandler(async (event) => {
await connectDB();
const token = getCookie(event, "auth-token");
if (!token) {
throw createError({
statusCode: 401,
statusMessage: "Not authenticated",
});
}
let memberId;
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
memberId = decoded.memberId;
} catch (err) {
throw createError({
statusCode: 401,
statusMessage: "Invalid or expired token",
});
}
const id = getRouterParam(event, "id");
try {
const update = await Update.findById(id);
if (!update) {
throw createError({
statusCode: 404,
statusMessage: "Update not found",
});
}
// Check if user is the author
if (update.author.toString() !== memberId) {
throw createError({
statusCode: 403,
statusMessage: "You can only delete your own updates",
});
}
await Update.findByIdAndDelete(id);
return { success: true };
} catch (error) {
if (error.statusCode) throw error;
console.error("Delete update error:", error);
throw createError({
statusCode: 500,
statusMessage: "Failed to delete update",
});
}
});

Some files were not shown because too many files have changed in this diff Show more