Adding features
329
CLAUDE.md
Normal 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
|
||||
|
|
@ -1,8 +1,13 @@
|
|||
export default defineAppConfig({
|
||||
ui: {
|
||||
colors: {
|
||||
primary: "pink",
|
||||
neutral: "zinc",
|
||||
primary: "emerald",
|
||||
neutral: "stone",
|
||||
},
|
||||
formField: {
|
||||
slots: {
|
||||
label: "block font-medium text-stone-200",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,13 +2,145 @@
|
|||
@import "tailwindcss";
|
||||
@import "@nuxt/ui";
|
||||
|
||||
@theme {
|
||||
@theme static {
|
||||
/* Font families */
|
||||
--font-sans: "Inter", sans-serif;
|
||||
--font-body: "Inter", sans-serif;
|
||||
--font-mono: "Ubuntu Mono", 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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,288 +1,31 @@
|
|||
<template>
|
||||
<footer
|
||||
class="py-16 border-t"
|
||||
:class="[
|
||||
backgroundClass,
|
||||
borderClass
|
||||
]"
|
||||
>
|
||||
<UContainer>
|
||||
<!-- Main Footer Content -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8 mb-12">
|
||||
<!-- 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 }}
|
||||
<footer class="mt-32 pb-16 px-8 md:px-12 lg:px-16">
|
||||
<!-- Minimal footer content -->
|
||||
<div class="max-w-4xl">
|
||||
<div
|
||||
class="flex flex-col md:flex-row justify-between items-start md:items-end gap-8"
|
||||
>
|
||||
<!-- Left: Copyright and minimal info -->
|
||||
<div>
|
||||
<p class="text-stone-500 text-xs mb-2">
|
||||
© {{ currentYear }} Ghost Guild
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Navigation Links -->
|
||||
<div class="lg:col-span-1">
|
||||
<h3 class="font-semibold mb-4" :class="headingColorClass">Navigation</h3>
|
||||
<ul class="space-y-2">
|
||||
<li v-for="link in navigationLinks" :key="link.path">
|
||||
<NuxtLink
|
||||
:to="link.path"
|
||||
:class="linkColorClass"
|
||||
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>
|
||||
<!-- Right: Contact links -->
|
||||
<div class="flex flex-wrap gap-6 text-xs">
|
||||
<a
|
||||
href="mailto:hello@ghostguild.org"
|
||||
class="text-stone-500 hover:text-stone-300 transition-colors"
|
||||
>
|
||||
Contact
|
||||
</a>
|
||||
</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>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
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
|
||||
})
|
||||
const currentYear = new Date().getFullYear();
|
||||
</script>
|
||||
|
|
@ -1,142 +1,191 @@
|
|||
<template>
|
||||
<nav class="border-b border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900">
|
||||
<UContainer>
|
||||
<div class="flex items-center justify-between py-4">
|
||||
<!-- Logo/Brand -->
|
||||
<NuxtLink to="/" class="flex items-center gap-2">
|
||||
<div class="w-8 h-8 bg-emerald-500 rounded-full flex items-center justify-center">
|
||||
<div class="w-4 h-4 bg-white rounded-sm" />
|
||||
</div>
|
||||
<div class="w-6 h-6 bg-emerald-500" style="clip-path: polygon(50% 0%, 0% 100%, 100% 100%)" />
|
||||
<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>
|
||||
<!-- Auth-based buttons -->
|
||||
<div v-if="isAuthenticated" class="flex items-center gap-3 ml-4">
|
||||
<UButton
|
||||
to="/member/dashboard"
|
||||
variant="solid"
|
||||
size="sm"
|
||||
>
|
||||
Dashboard
|
||||
</UButton>
|
||||
<UButton
|
||||
@click="logout"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
Logout
|
||||
</UButton>
|
||||
</div>
|
||||
<UButton
|
||||
v-else
|
||||
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"
|
||||
<nav
|
||||
class="w-64 lg:w-80 backdrop-blur-sm h-screen sticky top-0 flex flex-col"
|
||||
>
|
||||
<!-- Logo/Brand at top -->
|
||||
<div class="p-8 border-b border-ghost-800 bg-blue-400">
|
||||
<NuxtLink to="/" class="flex flex-col items-center gap-3 group">
|
||||
<span
|
||||
class="text-xl font-bold text-stone-100 ethereal-text tracking-wider"
|
||||
>Ghost Guild Logo</span
|
||||
>
|
||||
<div class="space-y-1">
|
||||
<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 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 }" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Navigation -->
|
||||
<div
|
||||
v-if="mobileMenuOpen"
|
||||
class="md:hidden py-4 border-t border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<div class="flex flex-col space-y-3">
|
||||
<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 py-2"
|
||||
active-class="text-emerald-600 dark:text-emerald-400"
|
||||
@click="mobileMenuOpen = false"
|
||||
>
|
||||
{{ item.label }}
|
||||
<!-- Vertical Navigation -->
|
||||
<div class="flex-1 p-8 overflow-y-auto">
|
||||
<ul class="space-y-6">
|
||||
<li v-for="item in navigationItems" :key="item.path">
|
||||
<NuxtLink :to="item.path" class="block group relative">
|
||||
<!-- Hover indicator -->
|
||||
|
||||
<span
|
||||
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"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</NuxtLink>
|
||||
<!-- Mobile auth buttons -->
|
||||
<div v-if="isAuthenticated" class="flex flex-col gap-3 mt-4">
|
||||
<UButton
|
||||
to="/member/dashboard"
|
||||
variant="solid"
|
||||
size="sm"
|
||||
class="w-fit"
|
||||
@click="mobileMenuOpen = false"
|
||||
>
|
||||
Dashboard
|
||||
</UButton>
|
||||
<UButton
|
||||
@click="logout; mobileMenuOpen = false"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="w-fit"
|
||||
>
|
||||
Logout
|
||||
</UButton>
|
||||
</div>
|
||||
<UButton
|
||||
v-else
|
||||
to="/login"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="mt-4 w-fit"
|
||||
@click="mobileMenuOpen = false"
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Auth section -->
|
||||
<div class="mt-12 pt-8 border-t border-ghost-800/50">
|
||||
<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
|
||||
</UButton>
|
||||
<span class="block text-sm text-whisper-400 mb-1">{{
|
||||
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>
|
||||
</UContainer>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { reactive, ref, computed } from "vue";
|
||||
|
||||
const mobileMenuOpen = ref(false)
|
||||
const { isAuthenticated, logout } = useAuth()
|
||||
const { isAuthenticated, logout, memberData } = useAuth();
|
||||
|
||||
const navigationItems = [
|
||||
{ label: 'Home', path: '/' },
|
||||
{ label: 'About', path: '/about' },
|
||||
{ label: 'Events', path: '/events' },
|
||||
{ label: 'Members', path: '/members' },
|
||||
{ label: 'Join', path: '/join' },
|
||||
{ label: 'Contact', path: '/contact' },
|
||||
]
|
||||
const publicNavigationItems = [
|
||||
{ label: "Home", path: "/", accent: "entry point" },
|
||||
{ label: "About", path: "/about", accent: "who we are" },
|
||||
{ label: "Events", path: "/events", accent: "gatherings" },
|
||||
{ label: "Join", path: "/join", accent: "become one" },
|
||||
{ label: "Contact", path: "/contact", accent: "reach out" },
|
||||
];
|
||||
|
||||
const toggleMobileMenu = () => {
|
||||
mobileMenuOpen.value = !mobileMenuOpen.value
|
||||
}
|
||||
const memberNavigationItems = [
|
||||
{ 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" },
|
||||
];
|
||||
|
||||
// Close mobile menu when clicking outside
|
||||
const closeMobileMenu = () => {
|
||||
mobileMenuOpen.value = false
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
.delay-75 {
|
||||
animation-delay: 75ms;
|
||||
}
|
||||
|
||||
.delay-150 {
|
||||
animation-delay: 150ms;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,19 +1,14 @@
|
|||
<template>
|
||||
<header
|
||||
class="py-16 md:py-24"
|
||||
:class="[
|
||||
backgroundClass,
|
||||
textColorClass
|
||||
]"
|
||||
class="relative py-16 md:py-24 bg-cover bg-center"
|
||||
style="background-image: url("/background-dither.png")"
|
||||
>
|
||||
<UContainer>
|
||||
<div class="absolute inset-0 bg-black/40"></div>
|
||||
<UContainer class="relative z-10">
|
||||
<div class="text-center max-w-4xl mx-auto">
|
||||
<h1
|
||||
class="font-bold mb-6 md:mb-8"
|
||||
:class="[
|
||||
titleSizeClass,
|
||||
titleColorClass
|
||||
]"
|
||||
:class="[titleSizeClass, titleColorClass]"
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
|
|
@ -27,31 +22,79 @@
|
|||
</p>
|
||||
|
||||
<!-- 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">
|
||||
<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')"
|
||||
>
|
||||
<svg 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
|
||||
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>
|
||||
</button>
|
||||
|
||||
<div class="text-center flex-1">
|
||||
<slot name="interactive-content">
|
||||
<p class="text-lg text-gray-600 dark:text-gray-300">
|
||||
{{ interactiveContent || 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.' }}
|
||||
<p
|
||||
: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>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<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')"
|
||||
>
|
||||
<svg 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
|
||||
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>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -79,94 +122,93 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
required: true,
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: ''
|
||||
default: "",
|
||||
},
|
||||
theme: {
|
||||
type: String,
|
||||
default: 'blue',
|
||||
validator: (value) => ['blue', 'purple', 'emerald', 'gray'].includes(value)
|
||||
default: "blue",
|
||||
validator: (value) =>
|
||||
["blue", "purple", "emerald", "gray", "ethereal"].includes(value),
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'large',
|
||||
validator: (value) => ['small', 'medium', 'large', 'hero'].includes(value)
|
||||
default: "large",
|
||||
validator: (value) => ["small", "medium", "large", "hero"].includes(value),
|
||||
},
|
||||
showInteractiveArea: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
default: false,
|
||||
},
|
||||
interactiveContent: {
|
||||
type: String,
|
||||
default: ''
|
||||
default: "",
|
||||
},
|
||||
showCta: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
default: false,
|
||||
},
|
||||
ctaText: {
|
||||
type: String,
|
||||
default: 'Get Started'
|
||||
default: "Get Started",
|
||||
},
|
||||
ctaLink: {
|
||||
type: String,
|
||||
default: '/join'
|
||||
default: "/join",
|
||||
},
|
||||
ctaSize: {
|
||||
type: String,
|
||||
default: 'lg'
|
||||
default: "lg",
|
||||
},
|
||||
ctaColor: {
|
||||
type: String,
|
||||
default: 'primary'
|
||||
}
|
||||
})
|
||||
default: "primary",
|
||||
},
|
||||
});
|
||||
|
||||
defineEmits(['prev', 'next'])
|
||||
defineEmits(["prev", "next"]);
|
||||
|
||||
const backgroundClass = computed(() => {
|
||||
const themes = {
|
||||
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',
|
||||
emerald: 'bg-gradient-to-br from-emerald-50 to-teal-100 dark:from-gray-900 dark:to-emerald-900/20',
|
||||
gray: 'bg-gray-50 dark:bg-gray-900'
|
||||
}
|
||||
return themes[props.theme] || themes.blue
|
||||
})
|
||||
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",
|
||||
emerald:
|
||||
"bg-gradient-to-br from-emerald-50 to-teal-100 dark:from-gray-900 dark:to-emerald-900/20",
|
||||
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 sizes = {
|
||||
small: 'text-2xl md:text-3xl',
|
||||
medium: 'text-3xl md:text-4xl',
|
||||
large: 'text-4xl md:text-5xl',
|
||||
hero: 'text-5xl md:text-6xl'
|
||||
}
|
||||
return sizes[props.size] || sizes.large
|
||||
})
|
||||
small: "text-2xl md:text-3xl",
|
||||
medium: "text-3xl md:text-4xl",
|
||||
large: "text-4xl md:text-5xl",
|
||||
hero: "text-5xl md:text-6xl",
|
||||
};
|
||||
return sizes[props.size] || sizes.large;
|
||||
});
|
||||
|
||||
const titleColorClass = computed(() => {
|
||||
const themes = {
|
||||
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
|
||||
})
|
||||
return "text-white";
|
||||
});
|
||||
|
||||
const subtitleColorClass = computed(() => {
|
||||
return 'text-gray-600 dark:text-gray-300'
|
||||
})
|
||||
return "text-white";
|
||||
});
|
||||
|
||||
const textColorClass = computed(() => {
|
||||
return 'text-gray-900 dark:text-white'
|
||||
})
|
||||
return "text-white";
|
||||
});
|
||||
</script>
|
||||
47
app/components/PrivacyToggle.vue
Normal 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>
|
||||
189
app/components/UpdateCard.vue
Normal 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>
|
||||
182
app/components/UpdateForm.vue
Normal 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>
|
||||
|
|
@ -1,7 +1,35 @@
|
|||
<template>
|
||||
<div>
|
||||
<AppNavigation />
|
||||
<slot />
|
||||
<AppFooter />
|
||||
<div class="min-h-screen bg-stone-800 flex relative">
|
||||
<!-- Background image at top - full page width -->
|
||||
<div
|
||||
class="absolute inset-x-0 pointer-events-none z-0"
|
||||
style="
|
||||
background-image: url("/background-dither.png");
|
||||
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>
|
||||
</template>
|
||||
|
|
@ -2,305 +2,277 @@
|
|||
<div>
|
||||
<!-- Page Header -->
|
||||
<PageHeader
|
||||
title="About"
|
||||
subtitle="Learn about Ghost Guild, our mission to support cooperative game development, and the community we're building together."
|
||||
title="About Our Membership Circles"
|
||||
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"
|
||||
size="large"
|
||||
/>
|
||||
|
||||
<!-- About Ghost Guild -->
|
||||
<!-- How Ghost Guild Works -->
|
||||
<section class="py-20 bg-white dark:bg-gray-900">
|
||||
<UContainer>
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="text-center mb-12">
|
||||
<h2 class="text-3xl font-bold text-blue-600 dark:text-blue-400 mb-8">
|
||||
About Ghost Guild
|
||||
</h2>
|
||||
</div>
|
||||
<h2 class="text-3xl font-bold text-gray-900 dark:text-white mb-6">
|
||||
How Ghost Guild Works
|
||||
</h2>
|
||||
|
||||
<div class="space-y-8">
|
||||
<!-- Main Description with Progress Bars -->
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 rounded-2xl p-8 border border-blue-200 dark:border-blue-800">
|
||||
<div class="space-y-6 mb-8">
|
||||
<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">
|
||||
<p class="text-xl font-semibold text-gray-900 dark:text-white mb-6">
|
||||
Everyone gets everything. Your circle reflects where you are in
|
||||
your cooperative journey. Your financial contribution reflects
|
||||
what you can afford. These are completely separate choices.
|
||||
</p>
|
||||
|
||||
<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 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<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>
|
||||
<p class="text-gray-600 dark:text-gray-300 leading-relaxed">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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 Vision</h3>
|
||||
<p class="text-gray-600 dark:text-gray-300 leading-relaxed">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ul
|
||||
class="text-lg leading-relaxed text-gray-700 dark:text-gray-300 space-y-3 mb-12"
|
||||
>
|
||||
<li>
|
||||
<strong>Equal access:</strong> The entire knowledge commons, all
|
||||
events, and full community participation
|
||||
</li>
|
||||
<li>
|
||||
<strong>Equal voice:</strong> One member, one vote in all
|
||||
decisions
|
||||
</li>
|
||||
<li>
|
||||
<strong>Solidarity economics:</strong> Pay what you can
|
||||
($0-50+/month), take what you need
|
||||
</li>
|
||||
<li>
|
||||
<strong>Value Flow integration:</strong> Contribute your skills,
|
||||
time, and knowledge - not just money
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</UContainer>
|
||||
</section>
|
||||
|
||||
<!-- Who It's For -->
|
||||
<!-- Find Your Circle -->
|
||||
<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">
|
||||
Who It's For
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<h2 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Find Your Circle
|
||||
</h2>
|
||||
<p class="text-gray-600 dark:text-gray-300 max-w-2xl mx-auto">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ghost Guild welcomes developers from all backgrounds and experience levels.
|
||||
<p class="text-lg text-gray-700 dark:text-gray-300 mb-12">
|
||||
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>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-12 max-w-5xl mx-auto">
|
||||
<!-- Game Developers -->
|
||||
<div class="flex items-start gap-6">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-16 h-16 bg-blue-100 dark:bg-blue-900/30 rounded-2xl flex items-center justify-center">
|
||||
<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
|
||||
<div class="space-y-12">
|
||||
<!-- Community 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">
|
||||
Community Circle
|
||||
</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 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 class="text-lg text-gray-600 dark:text-gray-400 mb-6">
|
||||
You're exploring what cooperatives could mean for your work
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Studio Founders -->
|
||||
<div class="flex items-start gap-6">
|
||||
<div class="flex-shrink-0">
|
||||
<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" />
|
||||
<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>
|
||||
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 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="flex items-start gap-6">
|
||||
<div class="flex-shrink-0">
|
||||
<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" />
|
||||
<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>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 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 class="flex items-start gap-6">
|
||||
<div class="flex-shrink-0">
|
||||
<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" />
|
||||
<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>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 class="flex-1">
|
||||
<h3 class="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
|
||||
Researchers & Advocates
|
||||
|
||||
<!-- Founder 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">
|
||||
Founder Circle
|
||||
</h3>
|
||||
<div class="space-y-2 mb-4">
|
||||
<div class="h-1 bg-yellow-500 rounded-full w-3/4" />
|
||||
<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 class="text-lg text-gray-600 dark:text-gray-400 mb-6">
|
||||
You're actively building or transitioning to a cooperative model
|
||||
</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>
|
||||
</UContainer>
|
||||
</section>
|
||||
|
||||
<!-- Our Values -->
|
||||
<!-- Important Notes -->
|
||||
<section class="py-20 bg-white dark:bg-gray-900">
|
||||
<UContainer>
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-3xl font-bold text-blue-600 dark:text-blue-400 mb-8">
|
||||
Our Values
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<h2 class="text-3xl font-bold text-gray-900 dark:text-white mb-8">
|
||||
Important Notes
|
||||
</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">
|
||||
<!-- Cooperation -->
|
||||
<div class="text-center">
|
||||
<div class="mb-6">
|
||||
<h3 class="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
|
||||
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.
|
||||
<div class="space-y-6 text-lg text-gray-700 dark:text-gray-300">
|
||||
<p>
|
||||
<strong>Movement between circles is fluid.</strong> As you move
|
||||
along in your journey, you can shift circles anytime. Just let us
|
||||
know.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Sustainability -->
|
||||
<div class="text-center">
|
||||
<div class="mb-6">
|
||||
<h3 class="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
|
||||
Sustainability
|
||||
</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>
|
||||
<strong>Your contribution is separate from your circle.</strong>
|
||||
Whether you contribute $0 or $50+/month, you get full access to
|
||||
everything. Choose based on your financial capacity, not your
|
||||
circle.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Democracy -->
|
||||
<div class="text-center">
|
||||
<div class="mb-6">
|
||||
<h3 class="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
|
||||
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>
|
||||
<strong>Not sure which circle?</strong> Start with Community - you
|
||||
can always move. Or email us and we'll chat about what makes sense
|
||||
for you.
|
||||
</p>
|
||||
</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>
|
||||
</UContainer>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -1,322 +1,204 @@
|
|||
<template>
|
||||
<div>
|
||||
<!-- Page Header -->
|
||||
<PageHeader
|
||||
title="Contact"
|
||||
subtitle="Get in touch with us. We're here to help and answer any questions you might have about Ghost Guild."
|
||||
theme="blue"
|
||||
size="large"
|
||||
/>
|
||||
<div class="relative">
|
||||
<!-- Hero Section -->
|
||||
<section class="mb-24">
|
||||
<div class="relative">
|
||||
<h1
|
||||
class="text-6xl md:text-8xl font-bold text-stone-100 ethereal-text leading-tight mb-8"
|
||||
>
|
||||
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 -->
|
||||
<section class="py-20 bg-white dark:bg-gray-900">
|
||||
<UContainer class="max-w-4xl">
|
||||
<div class="text-center mb-12">
|
||||
<h2 class="text-3xl font-bold text-blue-600 dark:text-blue-400 mb-4">
|
||||
Contact Form
|
||||
</h2>
|
||||
<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>
|
||||
<section class="mb-32 relative">
|
||||
<div class="mb-12">
|
||||
<h2 class="text-3xl font-light text-stone-200 mb-4">
|
||||
Send us a message (or email hello@ghostguild.org)
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-2xl p-8 shadow-xl border border-blue-200 dark:border-blue-800">
|
||||
<UForm :state="form" class="space-y-6" @submit="handleSubmit">
|
||||
<!-- Name and Email Row -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<UFormField label="Your Name" name="name" required>
|
||||
<UInput
|
||||
v-model="form.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"
|
||||
<div class="border border-ghost-700">
|
||||
<UForm :state="form" class="space-y-6" @submit="handleSubmit">
|
||||
<!-- Name and Email Row -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<UFormField label="Your Name" name="name" required>
|
||||
<UInput
|
||||
v-model="form.name"
|
||||
placeholder="Enter your full name"
|
||||
size="xl"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<!-- Message -->
|
||||
<UFormField label="Message" name="message" required>
|
||||
<UTextarea
|
||||
v-model="form.message"
|
||||
placeholder="Tell us how we can help you..."
|
||||
:rows="6"
|
||||
<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>
|
||||
|
||||
<!-- 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 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">
|
||||
<p class="text-red-700 dark:text-red-300 text-center">
|
||||
❌ {{ error }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UContainer>
|
||||
</section>
|
||||
<!-- Subject -->
|
||||
<UFormField label="Subject" name="subject" required>
|
||||
<USelect
|
||||
v-model="form.subject"
|
||||
:options="subjectOptions"
|
||||
placeholder="Select a subject"
|
||||
size="xl"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<!-- Support Information -->
|
||||
<section class="py-20 bg-gray-50 dark:bg-gray-800">
|
||||
<UContainer>
|
||||
<div class="text-center mb-12">
|
||||
<h2 class="text-3xl font-bold text-blue-600 dark:text-blue-400 mb-4">
|
||||
Support
|
||||
</h2>
|
||||
<p class="text-gray-600 dark:text-gray-300 max-w-2xl mx-auto">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Here are various ways to get help and support.
|
||||
<!-- Message -->
|
||||
<UFormField label="Message" name="message" required>
|
||||
<UTextarea
|
||||
v-model="form.message"
|
||||
placeholder="Tell us how we can help you..."
|
||||
:rows="6"
|
||||
size="xl"
|
||||
class="w-full"
|
||||
/>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
<!-- Help Center -->
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl p-8 shadow-lg text-center">
|
||||
<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.
|
||||
<div v-if="error" class="mt-6 p-4 border border-ghost-700 bg-ghost-900">
|
||||
<p class="text-stone-300 text-center">
|
||||
{{ error }}
|
||||
</p>
|
||||
|
||||
<UButton
|
||||
@click="scrollToForm"
|
||||
size="xl"
|
||||
color="primary"
|
||||
class="px-12 py-4"
|
||||
>
|
||||
Contact Us Now
|
||||
</UButton>
|
||||
</div>
|
||||
</UContainer>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, computed } from 'vue'
|
||||
import { reactive, ref, computed } from "vue";
|
||||
|
||||
// Form state
|
||||
const form = reactive({
|
||||
name: '',
|
||||
email: '',
|
||||
subject: '',
|
||||
message: '',
|
||||
newsletter: false
|
||||
})
|
||||
name: "",
|
||||
email: "",
|
||||
subject: "",
|
||||
message: "",
|
||||
newsletter: false,
|
||||
});
|
||||
|
||||
// UI state
|
||||
const isSubmitting = ref(false)
|
||||
const error = ref('')
|
||||
const success = ref(false)
|
||||
const isSubmitting = ref(false);
|
||||
const error = ref("");
|
||||
const success = ref(false);
|
||||
|
||||
// Subject options
|
||||
const subjectOptions = [
|
||||
{ label: 'General Inquiry', value: 'general' },
|
||||
{ label: 'Membership Questions', value: 'membership' },
|
||||
{ label: 'Technical Support', value: 'technical' },
|
||||
{ label: 'Partnership Opportunities', value: 'partnership' },
|
||||
{ label: 'Press & Media', value: 'media' },
|
||||
{ label: 'Other', value: 'other' }
|
||||
]
|
||||
{ label: "General Inquiry", value: "general" },
|
||||
{ label: "Membership Questions", value: "membership" },
|
||||
{ label: "Technical Support", value: "technical" },
|
||||
{ label: "Partnership Opportunities", value: "partnership" },
|
||||
{ label: "Press & Media", value: "media" },
|
||||
{ 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
|
||||
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
|
||||
const handleSubmit = async () => {
|
||||
if (isSubmitting.value) return
|
||||
if (isSubmitting.value) return;
|
||||
|
||||
isSubmitting.value = true
|
||||
error.value = ''
|
||||
success.value = false
|
||||
isSubmitting.value = true;
|
||||
error.value = "";
|
||||
success.value = false;
|
||||
|
||||
try {
|
||||
// Simulate API call
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
|
||||
// For now, just show success message
|
||||
success.value = true
|
||||
success.value = true;
|
||||
|
||||
// Reset form after success
|
||||
setTimeout(() => {
|
||||
Object.assign(form, {
|
||||
name: '',
|
||||
email: '',
|
||||
subject: '',
|
||||
message: '',
|
||||
newsletter: false
|
||||
})
|
||||
success.value = false
|
||||
}, 5000)
|
||||
|
||||
name: "",
|
||||
email: "",
|
||||
subject: "",
|
||||
message: "",
|
||||
newsletter: false,
|
||||
});
|
||||
success.value = false;
|
||||
}, 5000);
|
||||
} catch (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.'
|
||||
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.";
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll to form function
|
||||
const scrollToForm = () => {
|
||||
const formSection = document.querySelector('form')
|
||||
if (formSection) {
|
||||
formSection.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
|
@ -1,17 +1,30 @@
|
|||
<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="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-4"></div>
|
||||
<p class="text-gray-600 dark:text-gray-400">Loading event details...</p>
|
||||
<div
|
||||
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 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">
|
||||
<Icon name="heroicons:exclamation-triangle" class="w-16 h-16 text-red-500 mx-auto mb-4" />
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">Event Not Found</h2>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-6">The event you're looking for doesn't exist.</p>
|
||||
<NuxtLink to="/events" class="text-blue-600 dark:text-blue-400 hover:underline">
|
||||
<Icon
|
||||
name="heroicons:exclamation-triangle"
|
||||
class="w-16 h-16 text-red-500 mx-auto mb-4"
|
||||
/>
|
||||
<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
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
|
@ -19,65 +32,74 @@
|
|||
|
||||
<div v-else>
|
||||
<!-- Feature Image Header -->
|
||||
<div v-if="event.featureImage && (event.featureImage.publicId || event.featureImage.url)" class="relative h-96 overflow-hidden">
|
||||
<div
|
||||
v-if="
|
||||
event.featureImage &&
|
||||
(event.featureImage.publicId || event.featureImage.url)
|
||||
"
|
||||
class="relative h-96 overflow-hidden"
|
||||
>
|
||||
<img
|
||||
:src="getImageUrl(event.featureImage)"
|
||||
:alt="event.featureImage.alt || event.title"
|
||||
class="w-full h-full object-cover"
|
||||
@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">
|
||||
<UContainer>
|
||||
<div class="max-w-4xl">
|
||||
<h1 class="text-4xl md:text-5xl font-bold text-white mb-4">
|
||||
{{ event.title }}
|
||||
</h1>
|
||||
<p v-if="event.tagline" class="text-xl text-gray-200">
|
||||
{{ event.tagline }}
|
||||
</p>
|
||||
</div>
|
||||
</UContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Page Header (fallback when no image) -->
|
||||
<PageHeader
|
||||
v-else
|
||||
:title="event.title"
|
||||
:subtitle="event.tagline"
|
||||
theme="blue"
|
||||
size="medium"
|
||||
/>
|
||||
<PageHeader v-else :title="event.title" theme="blue" size="medium" />
|
||||
|
||||
<!-- Event Details Section -->
|
||||
<section class="py-16 bg-white dark:bg-gray-900">
|
||||
<section class="py-16 bg-stone-900">
|
||||
<UContainer>
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<!-- 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="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>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Date</p>
|
||||
<p class="font-semibold text-gray-900 dark:text-white">{{ formatDate(event.startDate) }}</p>
|
||||
<p class="text-sm text-stone-400">Date</p>
|
||||
<p class="font-semibold text-stone-100">
|
||||
{{ formatDate(event.startDate) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Time</p>
|
||||
<p class="font-semibold text-gray-900 dark:text-white">{{ formatTime(event.startDate, event.endDate) }}</p>
|
||||
<p class="text-sm text-stone-400">Time</p>
|
||||
<p class="font-semibold text-stone-100">
|
||||
{{ formatTime(event.startDate, event.endDate) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Location</p>
|
||||
<p class="font-semibold text-gray-900 dark:text-white">{{ event.location }}</p>
|
||||
<p class="text-sm text-stone-400">Location</p>
|
||||
<p class="font-semibold text-stone-100">
|
||||
{{ event.location }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -85,16 +107,22 @@
|
|||
|
||||
<!-- Event Cancelled Notice -->
|
||||
<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">
|
||||
<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>
|
||||
<h3 class="text-lg font-semibold text-red-700 dark:text-red-300 mb-2">Event Cancelled</h3>
|
||||
<p class="text-red-600 dark:text-red-400" v-if="event.cancellationMessage">
|
||||
<h3 class="text-lg font-semibold text-red-300 mb-2">
|
||||
Event Cancelled
|
||||
</h3>
|
||||
<p class="text-red-400" v-if="event.cancellationMessage">
|
||||
{{ event.cancellationMessage }}
|
||||
</p>
|
||||
<p class="text-red-600 dark:text-red-400" v-else>
|
||||
This event has been cancelled. We apologize for any inconvenience.
|
||||
<p class="text-red-400" v-else>
|
||||
This event has been cancelled. We apologize for any
|
||||
inconvenience.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -103,24 +131,36 @@
|
|||
|
||||
<!-- Member-Only Badge -->
|
||||
<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">
|
||||
<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">
|
||||
<div
|
||||
class="inline-flex items-center px-4 py-2 bg-purple-100 dark:bg-purple-900/30 rounded-full"
|
||||
>
|
||||
<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
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
<Icon name="heroicons:user-group" class="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Recommended for:</span>
|
||||
<Icon name="heroicons:user-group" class="w-5 h-5 text-blue-400" />
|
||||
<span class="text-sm font-medium text-stone-200"
|
||||
>Recommended for:</span
|
||||
>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="circle in event.targetCircles"
|
||||
: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) }}
|
||||
</span>
|
||||
|
|
@ -130,32 +170,64 @@
|
|||
|
||||
<!-- Event Description -->
|
||||
<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>
|
||||
<p class="text-gray-700 dark:text-gray-300">{{ event.description }}</p>
|
||||
<h2 class="text-2xl font-bold text-stone-100 mb-4">
|
||||
About This Event
|
||||
</h2>
|
||||
<p class="text-stone-200">
|
||||
{{ event.description }}
|
||||
</p>
|
||||
|
||||
<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">
|
||||
<li 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">
|
||||
<li
|
||||
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 }}
|
||||
</span>
|
||||
<span class="text-gray-700 dark:text-gray-300">{{ item }}</span>
|
||||
<span class="text-stone-200">{{ item }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="event.speakers && event.speakers.length > 0" class="mt-8">
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Speakers</h3>
|
||||
<div
|
||||
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 v-for="speaker in event.speakers" :key="speaker.name" class="flex items-start space-x-4">
|
||||
<div class="w-16 h-16 bg-gray-200 dark:bg-gray-700 rounded-full flex items-center justify-center">
|
||||
<Icon name="heroicons:user" class="w-8 h-8 text-gray-400 dark:text-gray-500" />
|
||||
<div
|
||||
v-for="speaker in event.speakers"
|
||||
: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>
|
||||
<p class="font-semibold text-gray-900 dark:text-white">{{ speaker.name }}</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{ speaker.role }}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-500 mt-1">{{ speaker.bio }}</p>
|
||||
<p class="font-semibold text-stone-100">
|
||||
{{ speaker.name }}
|
||||
</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>
|
||||
|
|
@ -163,30 +235,75 @@
|
|||
</div>
|
||||
|
||||
<!-- 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">
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-6">Register for This Event</h3>
|
||||
<div
|
||||
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 -->
|
||||
<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">
|
||||
<Icon name="heroicons:check-circle" class="w-6 h-6 text-green-600 dark:text-green-400 mr-3" />
|
||||
<div>
|
||||
<p class="font-semibold text-green-700 dark:text-green-300">You're registered!</p>
|
||||
<p class="text-sm text-green-600 dark:text-green-400">We've sent a confirmation to your email</p>
|
||||
<div
|
||||
class="p-4 bg-green-900/20 rounded-lg border border-green-800"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<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>
|
||||
|
||||
<!-- Member Gate Warning -->
|
||||
<div v-if="event.membersOnly && !isMember && registrationStatus !== 'registered'" class="mb-6">
|
||||
<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">
|
||||
<Icon name="heroicons:exclamation-triangle" class="w-6 h-6 text-amber-600 dark:text-amber-400 mr-3 mt-0.5" />
|
||||
<div
|
||||
v-if="
|
||||
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>
|
||||
<p class="font-semibold text-amber-700 dark:text-amber-300">Membership Required</p>
|
||||
<p class="text-sm text-amber-600 dark:text-amber-400 mt-1">
|
||||
This event is exclusive to Ghost Guild members. Join any circle to gain access.
|
||||
<p class="font-semibold text-amber-300">
|
||||
Membership Required
|
||||
</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
|
||||
<Icon name="heroicons:arrow-right" class="w-4 h-4 ml-1" />
|
||||
</NuxtLink>
|
||||
|
|
@ -195,11 +312,18 @@
|
|||
</div>
|
||||
|
||||
<!-- Registration Form -->
|
||||
<form v-if="registrationStatus !== 'registered'" @submit.prevent="handleRegistration" class="space-y-4">
|
||||
<form
|
||||
v-if="registrationStatus !== 'registered'"
|
||||
@submit.prevent="handleRegistration"
|
||||
class="space-y-4"
|
||||
>
|
||||
<!-- Show form fields only for public events OR for logged-in members -->
|
||||
<template v-if="!event.membersOnly || isMember">
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<label
|
||||
for="name"
|
||||
class="block text-sm font-medium text-stone-200 mb-2"
|
||||
>
|
||||
Full Name
|
||||
</label>
|
||||
<UInput
|
||||
|
|
@ -212,7 +336,10 @@
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<label
|
||||
for="email"
|
||||
class="block text-sm font-medium text-stone-200 mb-2"
|
||||
>
|
||||
Email Address
|
||||
</label>
|
||||
<UInput
|
||||
|
|
@ -225,7 +352,10 @@
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<label for="membershipLevel" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<label
|
||||
for="membershipLevel"
|
||||
class="block text-sm font-medium text-stone-200 mb-2"
|
||||
>
|
||||
Membership Status
|
||||
</label>
|
||||
<USelect
|
||||
|
|
@ -236,7 +366,6 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<div class="pt-4">
|
||||
<UButton
|
||||
v-if="!event.membersOnly || isMember"
|
||||
|
|
@ -246,18 +375,10 @@
|
|||
block
|
||||
:loading="isRegistering"
|
||||
>
|
||||
{{ isRegistering ? 'Registering...' : 'Register for Event' }}
|
||||
{{ isRegistering ? "Registering..." : "Register for Event" }}
|
||||
</UButton>
|
||||
<NuxtLink
|
||||
v-else
|
||||
to="/join"
|
||||
class="block"
|
||||
>
|
||||
<UButton
|
||||
color="primary"
|
||||
size="lg"
|
||||
block
|
||||
>
|
||||
<NuxtLink v-else to="/join" class="block">
|
||||
<UButton color="primary" size="lg" block>
|
||||
Become a Member to Register
|
||||
</UButton>
|
||||
</NuxtLink>
|
||||
|
|
@ -265,14 +386,19 @@
|
|||
</form>
|
||||
|
||||
<!-- 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">
|
||||
<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">
|
||||
<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 }}
|
||||
</span>
|
||||
<div class="w-24 h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="w-24 h-2 bg-stone-700 rounded-full overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="h-full bg-blue-500 rounded-full"
|
||||
:style="`width: ${((event.registeredCount || 0) / event.maxAttendees) * 100}%`"
|
||||
|
|
@ -284,12 +410,15 @@
|
|||
</div>
|
||||
|
||||
<!-- 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">
|
||||
<h4 class="font-semibold text-gray-900 dark:text-white mb-3">Questions?</h4>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300 mb-3">
|
||||
If you have any questions about this event, please reach out to our events team.
|
||||
<div class="mt-8 p-6 bg-stone-800 rounded-xl border border-stone-700">
|
||||
<h4 class="font-semibold text-stone-100 mb-3">Questions?</h4>
|
||||
<p class="text-sm text-stone-200 mb-3">
|
||||
If you have any questions about this event please drop us a line.
|
||||
</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" />
|
||||
events@ghostguild.org
|
||||
</a>
|
||||
|
|
@ -301,164 +430,254 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
const route = useRoute();
|
||||
const toast = useToast();
|
||||
|
||||
// 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
|
||||
if (error.value?.statusCode === 404) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'Event not found'
|
||||
})
|
||||
statusMessage: "Event not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Authentication
|
||||
const { isMember, memberData, checkMemberStatus } = useAuth()
|
||||
const { isMember, memberData, checkMemberStatus } = useAuth();
|
||||
|
||||
// Check member status on mount
|
||||
onMounted(async () => {
|
||||
await checkMemberStatus()
|
||||
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'
|
||||
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
|
||||
const registrationForm = ref({
|
||||
name: '',
|
||||
email: '',
|
||||
membershipLevel: 'non-member'
|
||||
})
|
||||
name: "",
|
||||
email: "",
|
||||
membershipLevel: "non-member",
|
||||
});
|
||||
|
||||
const membershipOptions = [
|
||||
{ label: 'Non-member', value: 'non-member' },
|
||||
{ label: 'Circle of Community', value: 'community' },
|
||||
{ label: 'Circle of Founders', value: 'founder' },
|
||||
{ label: 'Circle of Practitioners', value: 'practitioner' }
|
||||
]
|
||||
{ label: "Non-member", value: "non-member" },
|
||||
{ label: "Circle of Community", value: "community" },
|
||||
{ label: "Circle of Founders", value: "founder" },
|
||||
{ label: "Circle of Practitioners", value: "practitioner" },
|
||||
];
|
||||
|
||||
const isRegistering = ref(false)
|
||||
const registrationStatus = ref('not-registered') // 'not-registered', 'registered'
|
||||
const isRegistering = ref(false);
|
||||
const isCancelling = ref(false);
|
||||
const registrationStatus = ref("not-registered"); // 'not-registered', 'registered'
|
||||
|
||||
// Format date for display
|
||||
const formatDate = (dateString) => {
|
||||
const date = new Date(dateString)
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
}).format(date)
|
||||
}
|
||||
const date = new Date(dateString);
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
// Format time range for display
|
||||
const formatTime = (startDate, endDate) => {
|
||||
const start = new Date(startDate)
|
||||
const end = new Date(endDate)
|
||||
const start = new Date(startDate);
|
||||
const end = new Date(endDate);
|
||||
|
||||
const timeFormat = new Intl.DateTimeFormat('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
timeZoneName: 'short'
|
||||
})
|
||||
const timeFormat = new Intl.DateTimeFormat("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
timeZoneName: "short",
|
||||
});
|
||||
|
||||
return `${timeFormat.format(start)} - ${timeFormat.format(end)}`
|
||||
}
|
||||
return `${timeFormat.format(start)} - ${timeFormat.format(end)}`;
|
||||
};
|
||||
|
||||
// Format circle name for display
|
||||
const formatCircleName = (circleValue) => {
|
||||
const circleNames = {
|
||||
'community': 'Community Circle',
|
||||
'founder': 'Founder Circle',
|
||||
'practitioner': 'Practitioner Circle'
|
||||
}
|
||||
return circleNames[circleValue] || circleValue
|
||||
}
|
||||
community: "Community Circle",
|
||||
founder: "Founder Circle",
|
||||
practitioner: "Practitioner Circle",
|
||||
};
|
||||
return circleNames[circleValue] || circleValue;
|
||||
};
|
||||
|
||||
// Get optimized Cloudinary image URL
|
||||
const getOptimizedImageUrl = (publicId, transformations) => {
|
||||
if (!publicId) return ''
|
||||
if (!publicId) return "";
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
return `https://res.cloudinary.com/${config.public.cloudinaryCloudName}/image/upload/${transformations}/f_auto,q_auto/${publicId}`
|
||||
}
|
||||
const config = useRuntimeConfig();
|
||||
return `https://res.cloudinary.com/${config.public.cloudinaryCloudName}/image/upload/${transformations}/f_auto,q_auto/${publicId}`;
|
||||
};
|
||||
|
||||
// Get image URL with fallback logic
|
||||
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 (featureImage.url) {
|
||||
return featureImage.url
|
||||
return featureImage.url;
|
||||
}
|
||||
|
||||
// Fallback to Cloudinary if we have a 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
|
||||
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
|
||||
}
|
||||
};
|
||||
|
||||
// Handle registration submission
|
||||
const handleRegistration = async () => {
|
||||
isRegistering.value = true
|
||||
isRegistering.value = true;
|
||||
|
||||
try {
|
||||
// Submit registration to API using slug or ID
|
||||
const response = await $fetch(`/api/events/${route.params.id}/register`, {
|
||||
method: 'POST',
|
||||
body: registrationForm.value
|
||||
})
|
||||
method: "POST",
|
||||
body: registrationForm.value,
|
||||
});
|
||||
|
||||
// Update registration status
|
||||
registrationStatus.value = 'registered'
|
||||
registrationStatus.value = "registered";
|
||||
|
||||
// Show success toast
|
||||
toast.add({
|
||||
title: 'Registration Successful!',
|
||||
title: "Registration Successful!",
|
||||
description: `You're registered for ${event.value.title}. Check your email for confirmation.`,
|
||||
color: 'green'
|
||||
})
|
||||
color: "green",
|
||||
});
|
||||
|
||||
// Update registered count
|
||||
if (event.value.registeredCount !== undefined) {
|
||||
event.value.registeredCount++
|
||||
event.value.registeredCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Registration failed:', error)
|
||||
console.error("Registration failed:", error);
|
||||
|
||||
// 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({
|
||||
title: 'Registration Failed',
|
||||
title: "Registration Failed",
|
||||
description: errorMessage,
|
||||
color: 'red'
|
||||
})
|
||||
color: "red",
|
||||
});
|
||||
} 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
|
||||
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: [
|
||||
{ name: 'description', content: event.value?.description || 'View event details and register' }
|
||||
]
|
||||
}))
|
||||
{
|
||||
name: "description",
|
||||
content: event.value?.description || "View event details and register",
|
||||
},
|
||||
],
|
||||
}));
|
||||
</script>
|
||||
|
|
@ -3,105 +3,174 @@
|
|||
<!-- Page Header -->
|
||||
<PageHeader
|
||||
title="Events"
|
||||
subtitle="Join our community events, workshops, and gatherings designed to connect developers and share knowledge about cooperative game development."
|
||||
theme="blue"
|
||||
subtitle="Join our community events, workshops, and gatherings"
|
||||
size="large"
|
||||
/>
|
||||
|
||||
<!-- Event Calendar -->
|
||||
<section class="py-20 bg-white dark:bg-gray-900">
|
||||
<!-- Events Section with Tabs -->
|
||||
<section class="py-20 bg-stone-900 dark:bg-stone-950">
|
||||
<UContainer>
|
||||
<div class="text-center mb-12">
|
||||
<h2 class="text-3xl font-bold text-blue-600 dark:text-blue-400 mb-8">
|
||||
Event Calendar
|
||||
</h2>
|
||||
<div class="flex items-center justify-center gap-2 mb-8">
|
||||
<div class="w-6 h-6 bg-blue-500 rounded-full" />
|
||||
<div class="w-6 h-6 bg-blue-400 rounded-full" />
|
||||
<div class="w-8 h-1 bg-blue-300 rounded-full" />
|
||||
<div class="w-8 h-1 bg-blue-200 rounded-full" />
|
||||
<div class="w-8 h-1 bg-blue-100 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="max-w-5xl mx-auto">
|
||||
<div class="bg-gray-50 dark:bg-gray-800 rounded-2xl p-6 border border-gray-200 dark:border-gray-700">
|
||||
<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="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 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-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>
|
||||
<UTabs
|
||||
v-model="activeTab"
|
||||
:items="[
|
||||
{ label: 'Upcoming Events', value: 'upcoming', slot: 'upcoming' },
|
||||
{ label: 'Calendar', value: 'calendar', slot: 'calendar' },
|
||||
]"
|
||||
class="max-w-6xl mx-auto"
|
||||
>
|
||||
<template #upcoming>
|
||||
<div class="max-w-4xl mx-auto space-y-6 pt-8">
|
||||
<NuxtLink
|
||||
v-for="event in upcomingEvents"
|
||||
:key="event.id"
|
||||
:to="`/events/${event.slug || event.id}`"
|
||||
class="group flex items-start gap-4 py-2 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<div class="flex-shrink-0 text-center">
|
||||
<div class="text-2xl font-bold text-stone-100">
|
||||
{{ event.start.getDate() }}
|
||||
</div>
|
||||
<div class="text-xs text-stone-400 uppercase">
|
||||
{{
|
||||
event.start.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start gap-2 mb-1">
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<!-- Event Series -->
|
||||
<section v-if="activeSeries.length > 0" class="py-20 bg-purple-50 dark:bg-purple-900/20">
|
||||
<section
|
||||
v-if="activeSeries.length > 0"
|
||||
class="py-20 bg-stone-800 dark:bg-stone-900"
|
||||
>
|
||||
<UContainer>
|
||||
<div class="text-center mb-12">
|
||||
<h2 class="text-3xl font-bold text-purple-600 dark:text-purple-400 mb-8">
|
||||
<h2 class="text-3xl font-bold text-stone-100 mb-8">
|
||||
Active Event Series
|
||||
</h2>
|
||||
<p class="text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
|
||||
Multi-part workshops and recurring events designed to deepen your knowledge and build community connections.
|
||||
<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 class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-6xl mx-auto">
|
||||
<div
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-6xl mx-auto"
|
||||
>
|
||||
<div
|
||||
v-for="series in activeSeries.slice(0, 6)"
|
||||
:key="series.id"
|
||||
class="bg-white dark:bg-gray-900 rounded-xl p-6 shadow-lg border border-gray-200 dark:border-gray-700"
|
||||
class="bg-stone-900 rounded-xl p-6 shadow-lg border border-stone-700"
|
||||
>
|
||||
<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',
|
||||
getSeriesTypeBadgeClass(series.type)
|
||||
]">
|
||||
<div
|
||||
:class="[
|
||||
'inline-flex items-center px-3 py-1 rounded-full text-xs font-medium',
|
||||
getSeriesTypeBadgeClass(series.type),
|
||||
]"
|
||||
>
|
||||
{{ formatSeriesType(series.type) }}
|
||||
</div>
|
||||
<div class="flex items-center gap-1 text-xs text-gray-500">
|
||||
<div class="flex items-center gap-1 text-xs text-stone-400">
|
||||
<Icon name="heroicons:calendar-days" class="w-4 h-4" />
|
||||
<span>{{ series.eventCount }} events</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
<h3 class="text-lg font-semibold text-stone-100 mb-2">
|
||||
{{ series.title }}
|
||||
</h3>
|
||||
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4 line-clamp-2">
|
||||
<p class="text-sm text-stone-300 mb-4 line-clamp-2">
|
||||
{{ series.description }}
|
||||
</p>
|
||||
|
||||
|
|
@ -112,30 +181,39 @@
|
|||
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
|
||||
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-gray-600 dark:text-gray-400 truncate">{{ event.title }}</span>
|
||||
<span class="text-stone-300 truncate">{{ event.title }}</span>
|
||||
</div>
|
||||
<span class="text-gray-500 dark:text-gray-500">
|
||||
<span class="text-stone-400">
|
||||
{{ formatEventDate(event.startDate) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="series.events.length > 3" class="text-xs text-gray-500 dark:text-gray-500 text-center pt-1">
|
||||
<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 class="flex items-center justify-between text-sm">
|
||||
<div class="text-gray-500 dark:text-gray-500">
|
||||
<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'
|
||||
]">
|
||||
<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>
|
||||
|
|
@ -144,237 +222,101 @@
|
|||
</UContainer>
|
||||
</section>
|
||||
|
||||
<!-- Upcoming Events -->
|
||||
<section class="py-20 bg-gray-50 dark:bg-gray-800">
|
||||
<UContainer>
|
||||
<div class="text-center mb-12">
|
||||
<h2 class="text-3xl font-bold text-blue-600 dark:text-blue-400 mb-8">
|
||||
Upcoming Events
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-6xl mx-auto">
|
||||
<NuxtLink
|
||||
v-for="event in upcomingEvents"
|
||||
:key="event.id"
|
||||
:to="`/events/${event.slug || event.id}`"
|
||||
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"
|
||||
>
|
||||
<!-- Feature Image -->
|
||||
<div v-if="event.featureImage?.url" class="aspect-video w-full overflow-hidden">
|
||||
<img
|
||||
: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">
|
||||
<!-- Series Badge -->
|
||||
<div v-if="event.series?.isSeriesEvent" class="mb-3">
|
||||
<div class="inline-flex items-center gap-1 text-xs font-medium text-purple-600 dark:text-purple-400">
|
||||
<div class="w-4 h-4 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-bold">
|
||||
{{ event.series.position }}
|
||||
</div>
|
||||
<Icon name="heroicons:squares-2x2" class="w-3 h-3" />
|
||||
{{ event.series.title }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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',
|
||||
event.class === 'event-community' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' :
|
||||
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'
|
||||
]">
|
||||
{{ event.class === 'event-community' ? 'Community' :
|
||||
event.class === 'event-workshop' ? 'Workshop' :
|
||||
event.class === 'event-social' ? 'Social' : 'Showcase' }}
|
||||
</div>
|
||||
<Icon v-if="event.membersOnly" name="heroicons:lock-closed" class="w-4 h-4 text-purple-500" />
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</UContainer>
|
||||
</section>
|
||||
|
||||
<!-- 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>
|
||||
<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
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<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 class="space-y-6 mb-8">
|
||||
<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="bg-stone-900 rounded-2xl p-8 border border-stone-700 mb-12"
|
||||
>
|
||||
<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">
|
||||
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.
|
||||
<p class="text-lg leading-relaxed text-stone-200 mb-6">
|
||||
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 class="text-lg leading-relaxed text-gray-700 dark:text-gray-300 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.
|
||||
</p>
|
||||
|
||||
<p class="text-lg leading-relaxed text-gray-700 dark:text-gray-300">
|
||||
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.
|
||||
<p class="text-lg leading-relaxed text-stone-200 mb-6">
|
||||
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.
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<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">
|
||||
<div class="w-8 h-8 bg-blue-500 rounded" />
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">Monthly Meetups</h3>
|
||||
<div class="space-y-1 mb-3">
|
||||
<div class="h-1 bg-blue-500 rounded-full" />
|
||||
<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
|
||||
<h3 class="text-lg font-semibold text-stone-100 mb-2">
|
||||
Monthly Meetups
|
||||
</h3>
|
||||
|
||||
<p class="text-sm text-stone-300">
|
||||
Casual knowledge sharing sessions
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<div class="w-8 h-8 bg-emerald-500" style="clip-path: polygon(50% 0%, 0% 100%, 100% 100%)" />
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">Workshops</h3>
|
||||
<div class="space-y-1 mb-3">
|
||||
<div class="h-1 bg-emerald-500 rounded-full" />
|
||||
<div class="h-1 bg-emerald-300 rounded-full w-5/6 mx-auto" />
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Hands-on learning about cooperative business models
|
||||
<h3 class="text-lg font-semibold text-stone-100 mb-2">
|
||||
Workshops
|
||||
</h3>
|
||||
|
||||
<p class="text-sm text-stone-300">
|
||||
Hands-on learning about cooperative and worker-centric business
|
||||
models
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<div class="w-8 h-8 bg-purple-500 rounded-full" />
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">Social Events</h3>
|
||||
<div class="space-y-1 mb-3">
|
||||
<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
|
||||
<h3 class="text-lg font-semibold text-stone-100 mb-2">
|
||||
Social Events
|
||||
</h3>
|
||||
<p class="text-sm text-stone-300">
|
||||
Game nights, socials, and more
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UContainer>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { VueCal } from 'vue-cal'
|
||||
import 'vue-cal/style.css'
|
||||
import { VueCal } from "vue-cal";
|
||||
import "vue-cal/style.css";
|
||||
|
||||
// Active tab state
|
||||
const activeTab = ref("upcoming");
|
||||
|
||||
// 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')
|
||||
const { data: seriesData } = await useFetch("/api/series");
|
||||
|
||||
// Transform events for calendar display
|
||||
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,
|
||||
slug: event.slug,
|
||||
start: new Date(event.startDate),
|
||||
|
|
@ -388,117 +330,128 @@ const events = computed(() => {
|
|||
registeredCount: event.registeredCount,
|
||||
maxAttendees: event.maxAttendees,
|
||||
featureImage: event.featureImage,
|
||||
series: event.series
|
||||
}))
|
||||
})
|
||||
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
|
||||
)
|
||||
})
|
||||
if (!seriesData.value) return [];
|
||||
return seriesData.value.filter(
|
||||
(series) =>
|
||||
series.status === "active" || series.isOngoing || series.isUpcoming,
|
||||
);
|
||||
});
|
||||
|
||||
// Get upcoming events (future events)
|
||||
const upcomingEvents = computed(() => {
|
||||
const now = new Date()
|
||||
const now = new Date();
|
||||
return events.value
|
||||
.filter(event => event.start > now)
|
||||
.filter((event) => event.start > now)
|
||||
.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
|
||||
const formatEventDate = (date) => {
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
}).format(date)
|
||||
}
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
// Get optimized Cloudinary image URL
|
||||
const getOptimizedImageUrl = (publicId, transformations) => {
|
||||
if (!publicId) return ''
|
||||
if (!publicId) return "";
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
return `https://res.cloudinary.com/${config.public.cloudinaryCloudName}/image/upload/${transformations}/f_auto,q_auto/${publicId}`
|
||||
}
|
||||
const config = useRuntimeConfig();
|
||||
return `https://res.cloudinary.com/${config.public.cloudinaryCloudName}/image/upload/${transformations}/f_auto,q_auto/${publicId}`;
|
||||
};
|
||||
|
||||
// Get image URL with fallback logic
|
||||
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 (featureImage.url) {
|
||||
return featureImage.url
|
||||
return featureImage.url;
|
||||
}
|
||||
|
||||
// Fallback to Cloudinary if we have a 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
|
||||
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
|
||||
}
|
||||
};
|
||||
|
||||
// Handle calendar event click
|
||||
const onEventClick = (event) => {
|
||||
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
|
||||
}
|
||||
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'
|
||||
}
|
||||
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'
|
||||
if (!startDate || !endDate) return "No dates";
|
||||
|
||||
const start = new Date(startDate)
|
||||
const end = new Date(endDate)
|
||||
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()
|
||||
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}`
|
||||
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}`
|
||||
return `${startMonth} ${startDay} - ${endMonth} ${endDay}, ${year}`;
|
||||
} else {
|
||||
return `${formatEventDate(startDate)} - ${formatEventDate(endDate)}`
|
||||
return `${formatEventDate(startDate)} - ${formatEventDate(endDate)}`;
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -511,19 +464,127 @@ const formatDateRange = (startDate, endDate) => {
|
|||
|
||||
/* Custom calendar styling to match the site theme */
|
||||
.custom-calendar {
|
||||
--vuecal-primary-color: #3b82f6;
|
||||
--vuecal-text-color: #374151;
|
||||
--vuecal-border-color: #e5e7eb;
|
||||
--vuecal-header-color: #f9fafb;
|
||||
--vuecal-today-color: #dbeafe;
|
||||
--vuecal-primary-color: #fff;
|
||||
--vuecal-text-color: #e7e5e4;
|
||||
--vuecal-border-color: #57534e;
|
||||
--vuecal-header-color: #1c1917;
|
||||
--vuecal-today-color: #292524;
|
||||
background-color: #292524;
|
||||
}
|
||||
|
||||
.dark .custom-calendar {
|
||||
--vuecal-primary-color: #60a5fa;
|
||||
--vuecal-text-color: #d1d5db;
|
||||
--vuecal-border-color: #4b5563;
|
||||
--vuecal-header-color: #374151;
|
||||
--vuecal-today-color: #1e3a8a;
|
||||
.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 */
|
||||
|
|
|
|||
|
|
@ -1,104 +1,114 @@
|
|||
<template>
|
||||
<div>
|
||||
<!-- Hero Section -->
|
||||
<PageHeader
|
||||
title="Discover Ghost Guild"
|
||||
subtitle="A community for game developers exploring cooperative models"
|
||||
theme="blue"
|
||||
size="hero"
|
||||
: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."
|
||||
/>
|
||||
<div class="relative">
|
||||
<!-- Experimental Hero Section -->
|
||||
<section class="mb-24">
|
||||
<div class="relative">
|
||||
<!-- Large artistic title -->
|
||||
<h1
|
||||
class="text-6xl md:text-8xl font-bold text-stone-100 ethereal-text leading-tight mb-8"
|
||||
>
|
||||
Become a Ghostie
|
||||
</h1>
|
||||
|
||||
<!-- Join Us Today -->
|
||||
<section class="py-20 bg-white dark:bg-gray-900">
|
||||
<UContainer>
|
||||
<div class="text-center max-w-3xl mx-auto">
|
||||
<h2 class="text-4xl font-bold text-blue-600 dark:text-blue-400 mb-12">
|
||||
Join Us Today
|
||||
</h2>
|
||||
<!-- Floating subtitle -->
|
||||
<div class="mb-16">
|
||||
<p class="text-stone-100 text-lg max-w-md">
|
||||
A community for creatives and game devs<br />
|
||||
exploring cooperative models
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="h-2 bg-blue-500 rounded-full mx-auto max-w-sm" />
|
||||
<div class="h-12 bg-blue-500 rounded-xl mx-auto max-w-xs flex items-center justify-center">
|
||||
<UButton to="/join" size="lg" color="primary" class="text-white font-semibold">
|
||||
Get Started
|
||||
</UButton>
|
||||
<!-- Decorative elements -->
|
||||
<div
|
||||
class="absolute top-0 right-0 w-32 h-32 border border-ghost-800 rounded-full opacity-20"
|
||||
/>
|
||||
<div
|
||||
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>
|
||||
|
||||
<!-- Side accent -->
|
||||
</div>
|
||||
</UContainer>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- About Our Circles -->
|
||||
<section class="py-20 bg-gray-50 dark:bg-gray-800">
|
||||
<UContainer>
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-4xl font-bold text-blue-600 dark:text-blue-400 mb-8">
|
||||
About Our Circles
|
||||
</h2>
|
||||
<!-- Why Join? - Diagonal Layout -->
|
||||
<section class="mb-32 relative">
|
||||
<div class="transform -rotate-1">
|
||||
<h2 class="text-3xl font-light text-stone-200 mb-12">Why Join?</h2>
|
||||
</div>
|
||||
|
||||
<div class="ml-12 relative">
|
||||
<div
|
||||
class="absolute -left-4 top-0 w-32 h-px bg-whisper-500 opacity-30 transform rotate-12"
|
||||
/>
|
||||
|
||||
<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="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
|
||||
class="absolute -bottom-8 right-0 text-6xl text-stone-800 opacity-20 font-bold"
|
||||
>
|
||||
?
|
||||
</div>
|
||||
</UContainer>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Why Join? -->
|
||||
<section class="py-20 bg-white dark:bg-gray-900">
|
||||
<UContainer>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { getCircleOptions } from '~/config/circles'
|
||||
import { getCircleOptions } from "~/config/circles";
|
||||
|
||||
const circles = getCircleOptions()
|
||||
const circles = getCircleOptions();
|
||||
</script>
|
||||
|
|
@ -8,154 +8,632 @@
|
|||
size="medium"
|
||||
/>
|
||||
|
||||
<section class="py-12 bg-white dark:bg-gray-900">
|
||||
<UContainer>
|
||||
<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-blue-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
|
||||
<p class="text-gray-600 dark:text-gray-400">Loading your dashboard...</p>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<div v-else class="space-y-8">
|
||||
<!-- Welcome Section -->
|
||||
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 rounded-2xl p-8">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
<!-- 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-gray-600 dark:text-gray-300 mb-4">
|
||||
Your membership is active and you're part of our cooperative community.
|
||||
<p class="text-stone-300 mt-2">
|
||||
Your membership is active and you're part of our cooperative
|
||||
community.
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-4 text-sm">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg px-4 py-2 border">
|
||||
<span class="text-gray-500">Circle:</span>
|
||||
<span class="font-medium text-blue-600 dark:text-blue-400 ml-1 capitalize">{{ memberData?.circle }}</span>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg px-4 py-2 border">
|
||||
<span class="text-gray-500">Contribution:</span>
|
||||
<span class="font-medium text-green-600 dark:text-green-400 ml-1">${{ memberData?.contributionTier }} CAD/month</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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="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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-lg border border-gray-200 dark:border-gray-700">
|
||||
<div class="w-12 h-12 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center mb-4">
|
||||
<svg class="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<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>
|
||||
<h3 class="text-lg font-semibold mb-2 text-gray-900 dark:text-white">
|
||||
Upcoming Events
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-4">
|
||||
Discover and register for community events and workshops.
|
||||
</p>
|
||||
<UButton to="/events" variant="outline" size="sm">
|
||||
</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>
|
||||
</div>
|
||||
</template>
|
||||
</UCard>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-lg border border-gray-200 dark:border-gray-700">
|
||||
<div class="w-12 h-12 bg-green-100 dark:bg-green-900/30 rounded-lg flex items-center justify-center mb-4">
|
||||
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
<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>
|
||||
<h3 class="text-lg font-semibold mb-2 text-gray-900 dark:text-white">
|
||||
Community
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-4">
|
||||
Connect with other members in your circle and beyond.
|
||||
</p>
|
||||
<UButton to="/members" variant="outline" size="sm">
|
||||
</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>
|
||||
</div>
|
||||
</template>
|
||||
</UCard>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-lg border border-gray-200 dark:border-gray-700">
|
||||
<div class="w-12 h-12 bg-purple-100 dark:bg-purple-900/30 rounded-lg flex items-center justify-center mb-4">
|
||||
<svg class="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<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>
|
||||
<h3 class="text-lg font-semibold mb-2 text-gray-900 dark:text-white">
|
||||
Account Settings
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-4">
|
||||
Manage your profile and membership settings.
|
||||
</p>
|
||||
<UButton variant="outline" size="sm" disabled>
|
||||
Coming Soon
|
||||
</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>
|
||||
|
||||
<!-- Recent Activity (Placeholder) -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl p-8 shadow-lg border border-gray-200 dark:border-gray-700">
|
||||
<h2 class="text-xl font-bold mb-6 text-gray-900 dark:text-white">
|
||||
Recent Activity
|
||||
</h2>
|
||||
<div class="text-center py-12">
|
||||
<svg class="w-16 h-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
|
||||
</svg>
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||
No recent activity
|
||||
</h3>
|
||||
<p class="text-gray-500 dark:text-gray-400">
|
||||
Your activity and event history will appear here as you participate in the community.
|
||||
</p>
|
||||
<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>
|
||||
</UContainer>
|
||||
</section>
|
||||
|
||||
<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 { 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
|
||||
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)
|
||||
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 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
|
||||
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(' - ✅ Dashboard auth successful')
|
||||
return memberData.value
|
||||
})
|
||||
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'
|
||||
})
|
||||
title: "Member Dashboard - Ghost Guild",
|
||||
});
|
||||
|
||||
// Removed middleware - handling auth directly in the page component
|
||||
</script>
|
||||
211
app/pages/member/my-updates.vue
Normal 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
400
app/pages/members.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
135
app/pages/updates/[id]/edit.vue
Normal 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>
|
||||
153
app/pages/updates/[id]/index.vue
Normal 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
|
|
@ -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
|
|
@ -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>
|
||||
193
app/pages/updates/user/[id].vue
Normal 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>
|
||||
|
|
@ -3,6 +3,11 @@ export default defineNuxtConfig({
|
|||
compatibilityDate: "2025-07-15",
|
||||
devtools: { enabled: true },
|
||||
modules: ["@nuxt/eslint", "@nuxt/ui", "@nuxtjs/plausible"],
|
||||
ui: {
|
||||
theme: {
|
||||
colors: ['primary', 'neutral', 'ghost', 'whisper', 'sparkle']
|
||||
}
|
||||
},
|
||||
build: {
|
||||
transpile: ['vue-cal']
|
||||
},
|
||||
|
|
@ -16,6 +21,9 @@ export default defineNuxtConfig({
|
|||
jwtSecret: process.env.JWT_SECRET || 'dev-secret-change-in-production',
|
||||
resendApiKey: process.env.RESEND_API_KEY || '',
|
||||
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: {
|
||||
|
|
|
|||
1083
package-lock.json
generated
|
|
@ -14,8 +14,9 @@
|
|||
"@headlessui/vue": "^1.7.23",
|
||||
"@heroicons/vue": "^2.2.0",
|
||||
"@nuxt/eslint": "^1.9.0",
|
||||
"@nuxt/ui": "^3.3.2",
|
||||
"@nuxt/ui": "^4.0.0",
|
||||
"@nuxtjs/plausible": "^1.2.0",
|
||||
"@slack/web-api": "^7.10.0",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"chrono-node": "^2.8.4",
|
||||
"cloudinary": "^2.7.0",
|
||||
|
|
|
|||
BIN
public/background-dither.png
Normal file
|
After Width: | Height: | Size: 1 MiB |
|
Before Width: | Height: | Size: 4.2 KiB |
BIN
public/ghosties/Ghost-Disbelieving.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
17
public/ghosties/Ghost-Disbelieving.svg
Normal 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 |
BIN
public/ghosties/Ghost-Double-Take.png
Normal file
|
After Width: | Height: | Size: 3 KiB |
17
public/ghosties/Ghost-Double-Take.svg
Normal 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 |
BIN
public/ghosties/Ghost-Exasperated.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
17
public/ghosties/Ghost-Exasperated.svg
Normal 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 |
BIN
public/ghosties/Ghost-Mild.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
17
public/ghosties/Ghost-Mild.svg
Normal 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 |
BIN
public/ghosties/Ghost-Sweet.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
17
public/ghosties/Ghost-Sweet.svg
Normal 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 |
BIN
public/ghosties/Ghost-WTF.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
17
public/ghosties/Ghost-WTF.svg
Normal 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
|
|
@ -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();
|
||||
121
scripts/setup-helcim-plans.js
Normal 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);
|
||||
|
|
@ -1,49 +1,51 @@
|
|||
// server/api/auth/login.post.js
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { Resend } from 'resend'
|
||||
import Member from '../../models/member.js'
|
||||
import { connectDB } from '../../utils/mongoose.js'
|
||||
import jwt from "jsonwebtoken";
|
||||
import { Resend } from "resend";
|
||||
import Member from "../../models/member.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) => {
|
||||
// Connect to database
|
||||
await connectDB()
|
||||
await connectDB();
|
||||
|
||||
const { email } = await readBody(event)
|
||||
const { email } = await readBody(event);
|
||||
|
||||
if (!email) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Email is required'
|
||||
})
|
||||
statusMessage: "Email is required",
|
||||
});
|
||||
}
|
||||
|
||||
const member = await Member.findOne({ email })
|
||||
const member = await Member.findOne({ email });
|
||||
if (!member) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'No account found with that email address'
|
||||
})
|
||||
statusMessage: "No account found with that email address",
|
||||
});
|
||||
}
|
||||
|
||||
// Generate magic link token
|
||||
const token = jwt.sign(
|
||||
{ memberId: member._id },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '15m' } // Shorter expiry for security
|
||||
)
|
||||
{ expiresIn: "15m" }, // Shorter expiry for security
|
||||
);
|
||||
|
||||
// Get the base URL for the magic link
|
||||
const headers = getHeaders(event)
|
||||
const baseUrl = process.env.BASE_URL || `${headers.host?.includes('localhost') ? 'http' : 'https'}://${headers.host}`
|
||||
const headers = getHeaders(event);
|
||||
const baseUrl =
|
||||
process.env.BASE_URL ||
|
||||
`${headers.host?.includes("localhost") ? "http" : "https"}://${headers.host}`;
|
||||
|
||||
// Send magic link via Resend
|
||||
try {
|
||||
await resend.emails.send({
|
||||
from: 'Ghost Guild <noreply@ghostguild.org>',
|
||||
from: "Ghost Guild <ghostguild@babyghosts.org>",
|
||||
to: email,
|
||||
subject: 'Your Ghost Guild login link',
|
||||
subject: "Your Ghost Guild login link",
|
||||
html: `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h2 style="color: #2563eb;">Welcome back to Ghost Guild!</h2>
|
||||
|
|
@ -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.
|
||||
</p>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
`,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Login link sent to your email'
|
||||
}
|
||||
|
||||
message: "Login link sent to your email",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to send email:', error)
|
||||
console.error("Failed to send email:", error);
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Failed to send login email. Please try again.'
|
||||
})
|
||||
statusMessage: "Failed to send login email. Please try again.",
|
||||
});
|
||||
}
|
||||
})
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,45 +1,59 @@
|
|||
import jwt from 'jsonwebtoken'
|
||||
import Member from '../../models/member.js'
|
||||
import { connectDB } from '../../utils/mongoose.js'
|
||||
import jwt from "jsonwebtoken";
|
||||
import Member from "../../models/member.js";
|
||||
import { connectDB } from "../../utils/mongoose.js";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await connectDB()
|
||||
await connectDB();
|
||||
|
||||
const token = getCookie(event, 'auth-token')
|
||||
console.log('Auth check - token found:', !!token)
|
||||
const token = getCookie(event, "auth-token");
|
||||
console.log("Auth check - token found:", !!token);
|
||||
|
||||
if (!token) {
|
||||
console.log('No auth token found in cookies')
|
||||
console.log("No auth token found in cookies");
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Not authenticated'
|
||||
})
|
||||
statusMessage: "Not authenticated",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET)
|
||||
const member = await Member.findById(decoded.memberId).select('-__v')
|
||||
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'
|
||||
})
|
||||
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}`
|
||||
}
|
||||
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)
|
||||
console.error("Token verification error:", err);
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Invalid or expired token'
|
||||
})
|
||||
statusMessage: "Invalid or expired token",
|
||||
});
|
||||
}
|
||||
})
|
||||
});
|
||||
|
|
|
|||
19
server/api/contributions/options.get.js
Normal 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'
|
||||
})
|
||||
}
|
||||
})
|
||||
69
server/api/events/[id]/cancel-registration.post.js
Normal 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'
|
||||
});
|
||||
}
|
||||
});
|
||||
49
server/api/events/[id]/check-registration.post.js
Normal 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",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -1,130 +1,141 @@
|
|||
import Event from '../../../models/event.js'
|
||||
import Member from '../../../models/member.js'
|
||||
import { connectDB } from '../../../utils/mongoose.js'
|
||||
import mongoose from 'mongoose'
|
||||
import Event from "../../../models/event.js";
|
||||
import Member from "../../../models/member.js";
|
||||
import { connectDB } from "../../../utils/mongoose.js";
|
||||
import mongoose from "mongoose";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
// Ensure database connection
|
||||
await connectDB()
|
||||
const identifier = getRouterParam(event, 'id')
|
||||
const body = await readBody(event)
|
||||
await connectDB();
|
||||
const identifier = getRouterParam(event, "id");
|
||||
const body = await readBody(event);
|
||||
|
||||
if (!identifier) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Event identifier is required'
|
||||
})
|
||||
statusMessage: "Event identifier is required",
|
||||
});
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!body.name || !body.email) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Name and email are required'
|
||||
})
|
||||
statusMessage: "Name and email are required",
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch the event - try by slug first, then by ID
|
||||
let eventData
|
||||
let eventData;
|
||||
|
||||
// Check if identifier is a valid MongoDB ObjectId
|
||||
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 (!eventData) {
|
||||
eventData = await Event.findOne({ slug: identifier })
|
||||
eventData = await Event.findOne({ slug: identifier });
|
||||
}
|
||||
|
||||
if (!eventData) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'Event not found'
|
||||
})
|
||||
statusMessage: "Event not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if event is full
|
||||
if (eventData.maxAttendees && eventData.registrations.length >= eventData.maxAttendees) {
|
||||
if (
|
||||
eventData.maxAttendees &&
|
||||
eventData.registrations.length >= eventData.maxAttendees
|
||||
) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Event is full'
|
||||
})
|
||||
statusMessage: "Event is full",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if already registered
|
||||
const alreadyRegistered = eventData.registrations.some(
|
||||
reg => reg.email.toLowerCase() === body.email.toLowerCase()
|
||||
)
|
||||
(reg) => reg.email.toLowerCase() === body.email.toLowerCase(),
|
||||
);
|
||||
|
||||
if (alreadyRegistered) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'You are already registered for this event'
|
||||
})
|
||||
statusMessage: "You are already registered for this event",
|
||||
});
|
||||
}
|
||||
|
||||
// Check member status and handle different registration scenarios
|
||||
const member = await Member.findOne({ email: body.email.toLowerCase() })
|
||||
const member = await Member.findOne({ email: body.email.toLowerCase() });
|
||||
|
||||
if (eventData.membersOnly && !member) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: 'This event is for members only. Please become a member to register.'
|
||||
})
|
||||
statusMessage:
|
||||
"This event is for members only. Please become a member to register.",
|
||||
});
|
||||
}
|
||||
|
||||
// If event requires payment and user is not a member, redirect to payment flow
|
||||
if (eventData.pricing.paymentRequired && !eventData.pricing.isFree && !member) {
|
||||
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.'
|
||||
})
|
||||
statusMessage:
|
||||
"This event requires payment. Please use the payment registration endpoint.",
|
||||
});
|
||||
}
|
||||
|
||||
// Set member status and membership level
|
||||
let isMember = false
|
||||
let membershipLevel = 'non-member'
|
||||
let isMember = false;
|
||||
let membershipLevel = "non-member";
|
||||
|
||||
if (member) {
|
||||
isMember = true
|
||||
membershipLevel = `${member.circle}-${member.contributionTier}`
|
||||
isMember = true;
|
||||
membershipLevel = `${member.circle}-${member.contributionTier}`;
|
||||
}
|
||||
|
||||
// Add registration
|
||||
eventData.registrations.push({
|
||||
memberId: member ? member._id : null,
|
||||
name: body.name,
|
||||
email: body.email.toLowerCase(),
|
||||
membershipLevel,
|
||||
isMember,
|
||||
paymentStatus: 'not_required', // Free events or member registrations
|
||||
paymentStatus: "not_required", // Free events or member registrations
|
||||
amountPaid: 0,
|
||||
dietary: body.dietary || false,
|
||||
registeredAt: new Date()
|
||||
})
|
||||
registeredAt: new Date(),
|
||||
});
|
||||
|
||||
// Save the updated event
|
||||
await eventData.save()
|
||||
await eventData.save();
|
||||
|
||||
// TODO: Send confirmation email using Resend
|
||||
// await sendEventRegistrationEmail(body.email, eventData)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Successfully registered for the event',
|
||||
registrationId: eventData.registrations[eventData.registrations.length - 1]._id
|
||||
}
|
||||
message: "Successfully registered for the event",
|
||||
registrationId:
|
||||
eventData.registrations[eventData.registrations.length - 1]._id,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error registering for event:', error)
|
||||
console.error("Error registering for event:", error);
|
||||
|
||||
if (error.statusCode) {
|
||||
throw error
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Failed to register for event'
|
||||
})
|
||||
statusMessage: "Failed to register for event",
|
||||
});
|
||||
}
|
||||
})
|
||||
});
|
||||
|
|
|
|||
61
server/api/helcim/create-plan.post.js
Normal 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'
|
||||
})
|
||||
}
|
||||
})
|
||||
83
server/api/helcim/customer-code.get.js
Normal 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'
|
||||
})
|
||||
}
|
||||
})
|
||||
130
server/api/helcim/get-or-create-customer.post.js
Normal 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'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
@ -2,9 +2,74 @@
|
|||
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()
|
||||
|
|
@ -44,6 +109,9 @@ export default defineEventHandler(async (event) => {
|
|||
|
||||
console.log('Updated member for free tier:', member)
|
||||
|
||||
// Send Slack invitation for free tier members
|
||||
await inviteToSlack(member)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
subscription: null,
|
||||
|
|
@ -82,6 +150,9 @@ export default defineEventHandler(async (event) => {
|
|||
{ new: true }
|
||||
)
|
||||
|
||||
// Send Slack invitation even when no plan is configured
|
||||
await inviteToSlack(member)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
subscription: {
|
||||
|
|
@ -193,6 +264,9 @@ export default defineEventHandler(async (event) => {
|
|||
{ new: true }
|
||||
)
|
||||
|
||||
// Send Slack invitation even when subscription setup fails
|
||||
await inviteToSlack(member)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
subscription: {
|
||||
|
|
@ -234,6 +308,9 @@ export default defineEventHandler(async (event) => {
|
|||
{ new: true }
|
||||
)
|
||||
|
||||
// Send Slack invitation for paid tier members
|
||||
await inviteToSlack(member)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
subscription: {
|
||||
|
|
@ -260,6 +337,9 @@ export default defineEventHandler(async (event) => {
|
|||
{ new: true }
|
||||
)
|
||||
|
||||
// Send Slack invitation even when subscription fetch fails
|
||||
await inviteToSlack(member)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
subscription: {
|
||||
|
|
|
|||
100
server/api/members/cancel-subscription.post.js
Normal 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",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -1,9 +1,74 @@
|
|||
// server/api/members/create.post.js
|
||||
import Member from '../../models/member.js'
|
||||
import { connectDB } from '../../utils/mongoose.js'
|
||||
import { getSlackService } from '../../utils/slack.ts'
|
||||
// Simple payment check function to avoid import issues
|
||||
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) => {
|
||||
// Ensure database is connected
|
||||
await connectDB()
|
||||
|
|
@ -23,6 +88,9 @@ export default defineEventHandler(async (event) => {
|
|||
const member = new Member(body)
|
||||
await member.save()
|
||||
|
||||
// Send Slack invitation for new members
|
||||
await inviteToSlack(member)
|
||||
|
||||
// TODO: Process payment with Helcim if not free tier
|
||||
if (requiresPayment(body.contributionTier)) {
|
||||
// Payment processing will be added here
|
||||
|
|
|
|||
115
server/api/members/directory.get.js
Normal 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",
|
||||
});
|
||||
}
|
||||
});
|
||||
60
server/api/members/my-events.get.js
Normal 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",
|
||||
});
|
||||
}
|
||||
});
|
||||
117
server/api/members/profile.patch.js
Normal 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",
|
||||
});
|
||||
}
|
||||
});
|
||||
354
server/api/members/update-contribution.post.js
Normal 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",
|
||||
});
|
||||
}
|
||||
});
|
||||
68
server/api/slack/test-bot.get.ts
Normal 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'
|
||||
}
|
||||
}
|
||||
})
|
||||
59
server/api/updates/[id].delete.js
Normal 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",
|
||||
});
|
||||
}
|
||||
});
|
||||
60
server/api/updates/[id].get.js
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import jwt from "jsonwebtoken";
|
||||
import Update from "../../models/update.js";
|
||||
import { connectDB } from "../../utils/mongoose.js";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await connectDB();
|
||||
|
||||
const id = getRouterParam(event, "id");
|
||||
const token = getCookie(event, "auth-token");
|
||||
let memberId = null;
|
||||
|
||||
// Check if user is authenticated
|
||||
if (token) {
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
memberId = decoded.memberId;
|
||||
} catch (err) {
|
||||
// Token invalid, continue as non-member
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const update = await Update.findById(id).populate("author", "name avatar");
|
||||
|
||||
if (!update) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: "Update not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Check privacy permissions
|
||||
if (update.privacy === "private") {
|
||||
// Only author can view private updates
|
||||
if (!memberId || update.author._id.toString() !== memberId) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: "You don't have permission to view this update",
|
||||
});
|
||||
}
|
||||
} else if (update.privacy === "members") {
|
||||
// Must be authenticated to view members-only updates
|
||||
if (!memberId) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: "You must be a member to view this update",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return update;
|
||||
} catch (error) {
|
||||
if (error.statusCode) throw error;
|
||||
console.error("Get update error:", error);
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: "Failed to fetch update",
|
||||
});
|
||||
}
|
||||
});
|
||||
68
server/api/updates/[id].patch.js
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
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");
|
||||
const body = await readBody(event);
|
||||
|
||||
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 edit your own updates",
|
||||
});
|
||||
}
|
||||
|
||||
// Update allowed fields
|
||||
if (body.content !== undefined) update.content = body.content;
|
||||
if (body.images !== undefined) update.images = body.images;
|
||||
if (body.privacy !== undefined) update.privacy = body.privacy;
|
||||
if (body.commentsEnabled !== undefined)
|
||||
update.commentsEnabled = body.commentsEnabled;
|
||||
|
||||
await update.save();
|
||||
await update.populate("author", "name avatar");
|
||||
|
||||
return update;
|
||||
} catch (error) {
|
||||
if (error.statusCode) throw error;
|
||||
console.error("Update edit error:", error);
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: "Failed to update",
|
||||
});
|
||||
}
|
||||
});
|
||||
56
server/api/updates/index.get.js
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
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");
|
||||
let memberId = null;
|
||||
|
||||
// Check if user is authenticated
|
||||
if (token) {
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
memberId = decoded.memberId;
|
||||
} catch (err) {
|
||||
// Token invalid, continue as non-member
|
||||
}
|
||||
}
|
||||
|
||||
const query = getQuery(event);
|
||||
const limit = parseInt(query.limit) || 20;
|
||||
const skip = parseInt(query.skip) || 0;
|
||||
|
||||
try {
|
||||
// Build privacy filter
|
||||
let privacyFilter;
|
||||
if (!memberId) {
|
||||
// Not authenticated - only show public updates
|
||||
privacyFilter = { privacy: "public" };
|
||||
} else {
|
||||
// Authenticated member - show public and members-only updates
|
||||
privacyFilter = { privacy: { $in: ["public", "members"] } };
|
||||
}
|
||||
|
||||
const updates = await Update.find(privacyFilter)
|
||||
.populate("author", "name avatar")
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(limit)
|
||||
.skip(skip);
|
||||
|
||||
const total = await Update.countDocuments(privacyFilter);
|
||||
|
||||
return {
|
||||
updates,
|
||||
total,
|
||||
hasMore: skip + limit < total,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Get updates error:", error);
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: "Failed to fetch updates",
|
||||
});
|
||||
}
|
||||
});
|
||||
57
server/api/updates/index.post.js
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
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 body = await readBody(event);
|
||||
|
||||
if (!body.content || !body.content.trim()) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Content is required",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const update = await Update.create({
|
||||
author: memberId,
|
||||
content: body.content,
|
||||
images: body.images || [],
|
||||
privacy: body.privacy || "members",
|
||||
commentsEnabled: body.commentsEnabled ?? true,
|
||||
});
|
||||
|
||||
// Populate author details
|
||||
await update.populate("author", "name avatar");
|
||||
|
||||
return update;
|
||||
} catch (error) {
|
||||
console.error("Create update error:", error);
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: "Failed to create update",
|
||||
});
|
||||
}
|
||||
});
|
||||
53
server/api/updates/my-updates.get.js
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
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 query = getQuery(event);
|
||||
const limit = parseInt(query.limit) || 20;
|
||||
const skip = parseInt(query.skip) || 0;
|
||||
|
||||
try {
|
||||
const updates = await Update.find({ author: memberId })
|
||||
.populate("author", "name avatar")
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(limit)
|
||||
.skip(skip);
|
||||
|
||||
const total = await Update.countDocuments({ author: memberId });
|
||||
|
||||
return {
|
||||
updates,
|
||||
total,
|
||||
hasMore: skip + limit < total,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Get my updates error:", error);
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: "Failed to fetch updates",
|
||||
});
|
||||
}
|
||||
});
|
||||
76
server/api/updates/user/[id].get.js
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import jwt from "jsonwebtoken";
|
||||
import Update from "../../../models/update.js";
|
||||
import Member from "../../../models/member.js";
|
||||
import { connectDB } from "../../../utils/mongoose.js";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await connectDB();
|
||||
|
||||
const userId = getRouterParam(event, "id");
|
||||
const token = getCookie(event, "auth-token");
|
||||
let currentMemberId = null;
|
||||
|
||||
// Check if user is authenticated
|
||||
if (token) {
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
currentMemberId = decoded.memberId;
|
||||
} catch (err) {
|
||||
// Token invalid, continue as non-member
|
||||
}
|
||||
}
|
||||
|
||||
const query = getQuery(event);
|
||||
const limit = parseInt(query.limit) || 20;
|
||||
const skip = parseInt(query.skip) || 0;
|
||||
|
||||
try {
|
||||
// Verify the user exists
|
||||
const user = await Member.findById(userId);
|
||||
if (!user) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Build privacy filter
|
||||
let privacyFilter;
|
||||
if (!currentMemberId) {
|
||||
// Not authenticated - only show public updates
|
||||
privacyFilter = { author: userId, privacy: "public" };
|
||||
} else if (currentMemberId === userId) {
|
||||
// Viewing own updates - show all
|
||||
privacyFilter = { author: userId };
|
||||
} else {
|
||||
// Authenticated member viewing another's updates - show public and members-only
|
||||
privacyFilter = { author: userId, privacy: { $in: ["public", "members"] } };
|
||||
}
|
||||
|
||||
const updates = await Update.find(privacyFilter)
|
||||
.populate("author", "name avatar")
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(limit)
|
||||
.skip(skip);
|
||||
|
||||
const total = await Update.countDocuments(privacyFilter);
|
||||
|
||||
return {
|
||||
updates,
|
||||
total,
|
||||
hasMore: skip + limit < total,
|
||||
user: {
|
||||
_id: user._id,
|
||||
name: user.name,
|
||||
avatar: user.avatar,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (error.statusCode) throw error;
|
||||
console.error("Get user updates error:", error);
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: "Failed to fetch user updates",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -4,68 +4,65 @@
|
|||
// Central configuration for Ghost Guild Contribution Levels and Helcim Plans
|
||||
export const CONTRIBUTION_TIERS = {
|
||||
FREE: {
|
||||
value: '0',
|
||||
value: "0",
|
||||
amount: 0,
|
||||
label: '$0 - I need support right now',
|
||||
tier: 'free',
|
||||
label: "$0 - I need support right now",
|
||||
tier: "free",
|
||||
helcimPlanId: null, // No Helcim plan needed for free tier
|
||||
features: [
|
||||
'Access to basic resources',
|
||||
'Community forum access'
|
||||
]
|
||||
features: ["Access to basic resources", "Community forum access"],
|
||||
},
|
||||
SUPPORTER: {
|
||||
value: '5',
|
||||
value: "5",
|
||||
amount: 5,
|
||||
label: '$5 - I can contribute a little',
|
||||
tier: 'supporter',
|
||||
label: "$5 - I can contribute a little",
|
||||
tier: "supporter",
|
||||
helcimPlanId: 20162,
|
||||
features: [
|
||||
'All Free Membership benefits',
|
||||
'Priority community support',
|
||||
'Early access to events'
|
||||
]
|
||||
"All Free Membership benefits",
|
||||
"Priority community support",
|
||||
"Early access to events",
|
||||
],
|
||||
},
|
||||
MEMBER: {
|
||||
value: '15',
|
||||
value: "15",
|
||||
amount: 15,
|
||||
label: '$15 - I can sustain the community',
|
||||
tier: 'member',
|
||||
helcimPlanId: null, // TODO: Create $15/month plan in Helcim dashboard
|
||||
label: "$15 - I can sustain the community",
|
||||
tier: "member",
|
||||
helcimPlanId: 21596,
|
||||
features: [
|
||||
'All Supporter benefits',
|
||||
'Access to premium workshops',
|
||||
'Monthly 1-on-1 sessions',
|
||||
'Advanced resource library'
|
||||
]
|
||||
"All Supporter benefits",
|
||||
"Access to premium workshops",
|
||||
"Monthly 1-on-1 sessions",
|
||||
"Advanced resource library",
|
||||
],
|
||||
},
|
||||
ADVOCATE: {
|
||||
value: '30',
|
||||
value: "30",
|
||||
amount: 30,
|
||||
label: '$30 - I can support others too',
|
||||
tier: 'advocate',
|
||||
helcimPlanId: null, // TODO: Create $30/month plan in Helcim dashboard
|
||||
label: "$30 - I can support others too",
|
||||
tier: "advocate",
|
||||
helcimPlanId: 21597,
|
||||
features: [
|
||||
'All Member benefits',
|
||||
'Weekly group mentoring',
|
||||
'Access to exclusive events',
|
||||
'Direct messaging with experts'
|
||||
]
|
||||
"All Member benefits",
|
||||
"Weekly group mentoring",
|
||||
"Access to exclusive events",
|
||||
"Direct messaging with experts",
|
||||
],
|
||||
},
|
||||
CHAMPION: {
|
||||
value: '50',
|
||||
value: "50",
|
||||
amount: 50,
|
||||
label: '$50 - I want to sponsor multiple members',
|
||||
tier: 'champion',
|
||||
helcimPlanId: null, // TODO: Create $50/month plan in Helcim dashboard
|
||||
label: "$50 - I want to sponsor multiple members",
|
||||
tier: "champion",
|
||||
helcimPlanId: 21598,
|
||||
features: [
|
||||
'All Advocate benefits',
|
||||
'Personal mentoring sessions',
|
||||
'VIP event access',
|
||||
'Custom project support',
|
||||
'Annual strategy session'
|
||||
]
|
||||
}
|
||||
"All Advocate benefits",
|
||||
"Personal mentoring sessions",
|
||||
"VIP event access",
|
||||
"Custom project support",
|
||||
"Annual strategy session",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// Get all contribution options as an array (useful for forms)
|
||||
|
|
@ -75,12 +72,12 @@ export const getContributionOptions = () => {
|
|||
|
||||
// Get valid contribution values for validation
|
||||
export const getValidContributionValues = () => {
|
||||
return Object.values(CONTRIBUTION_TIERS).map(tier => tier.value);
|
||||
return Object.values(CONTRIBUTION_TIERS).map((tier) => tier.value);
|
||||
};
|
||||
|
||||
// Get contribution tier by value
|
||||
export const getContributionTierByValue = (value) => {
|
||||
return Object.values(CONTRIBUTION_TIERS).find(tier => tier.value === value);
|
||||
return Object.values(CONTRIBUTION_TIERS).find((tier) => tier.value === value);
|
||||
};
|
||||
|
||||
// Get Helcim plan ID for a contribution tier
|
||||
|
|
@ -102,10 +99,12 @@ export const isValidContributionValue = (value) => {
|
|||
|
||||
// Get contribution tier by Helcim plan ID
|
||||
export const getContributionTierByHelcimPlan = (helcimPlanId) => {
|
||||
return Object.values(CONTRIBUTION_TIERS).find(tier => tier.helcimPlanId === helcimPlanId);
|
||||
return Object.values(CONTRIBUTION_TIERS).find(
|
||||
(tier) => tier.helcimPlanId === helcimPlanId,
|
||||
);
|
||||
};
|
||||
|
||||
// Get paid tiers only (excluding free tier)
|
||||
export const getPaidContributionTiers = () => {
|
||||
return Object.values(CONTRIBUTION_TIERS).filter(tier => tier.amount > 0);
|
||||
return Object.values(CONTRIBUTION_TIERS).filter((tier) => tier.amount > 0);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import mongoose from 'mongoose'
|
||||
import mongoose from "mongoose";
|
||||
|
||||
const eventSchema = new mongoose.Schema({
|
||||
title: { type: String, required: true },
|
||||
|
|
@ -9,14 +9,14 @@ const eventSchema = new mongoose.Schema({
|
|||
featureImage: {
|
||||
url: String, // Cloudinary URL
|
||||
publicId: String, // Cloudinary public ID for transformations
|
||||
alt: String // Alt text for accessibility
|
||||
alt: String, // Alt text for accessibility
|
||||
},
|
||||
startDate: { type: Date, required: true },
|
||||
endDate: { type: Date, required: true },
|
||||
eventType: {
|
||||
type: String,
|
||||
enum: ['community', 'workshop', 'social', 'showcase'],
|
||||
default: 'community'
|
||||
enum: ["community", "workshop", "social", "showcase"],
|
||||
default: "community",
|
||||
},
|
||||
// Online-first location handling
|
||||
location: {
|
||||
|
|
@ -24,14 +24,15 @@ const eventSchema = new mongoose.Schema({
|
|||
required: true,
|
||||
// This will typically be a Slack channel or video conference link
|
||||
validate: {
|
||||
validator: function(v) {
|
||||
validator: function (v) {
|
||||
// Must be either a valid URL or a Slack channel reference
|
||||
const urlPattern = /^https?:\/\/.+/;
|
||||
const slackPattern = /^#[a-zA-Z0-9-_]+$/;
|
||||
return urlPattern.test(v) || slackPattern.test(v);
|
||||
},
|
||||
message: 'Location must be a valid URL (video conference link) or Slack channel (starting with #)'
|
||||
}
|
||||
message:
|
||||
"Location must be a valid URL (video conference link) or Slack channel (starting with #)",
|
||||
},
|
||||
},
|
||||
isOnline: { type: Boolean, default: true }, // Default to online-first
|
||||
// Visibility and status controls
|
||||
|
|
@ -46,97 +47,110 @@ const eventSchema = new mongoose.Schema({
|
|||
description: String, // Series description
|
||||
type: {
|
||||
type: String,
|
||||
enum: ['workshop_series', 'recurring_meetup', 'multi_day', 'course', 'tournament'],
|
||||
default: 'workshop_series'
|
||||
enum: [
|
||||
"workshop_series",
|
||||
"recurring_meetup",
|
||||
"multi_day",
|
||||
"course",
|
||||
"tournament",
|
||||
],
|
||||
default: "workshop_series",
|
||||
},
|
||||
position: Number, // Order within the series (e.g., 1 = first event, 2 = second, etc.)
|
||||
totalEvents: Number, // Total planned events in the series
|
||||
isSeriesEvent: { type: Boolean, default: false } // Flag to identify series events
|
||||
isSeriesEvent: { type: Boolean, default: false }, // Flag to identify series events
|
||||
},
|
||||
// Event pricing for public attendees (deprecated - use tickets instead)
|
||||
pricing: {
|
||||
isFree: { type: Boolean, default: true },
|
||||
publicPrice: { type: Number, default: 0 }, // Price for non-members
|
||||
currency: { type: String, default: 'CAD' },
|
||||
paymentRequired: { type: Boolean, default: false }
|
||||
currency: { type: String, default: "CAD" },
|
||||
paymentRequired: { type: Boolean, default: false },
|
||||
},
|
||||
// Ticket configuration
|
||||
tickets: {
|
||||
enabled: { type: Boolean, default: false },
|
||||
public: {
|
||||
available: { type: Boolean, default: false },
|
||||
name: { type: String, default: 'Public Ticket' },
|
||||
name: { type: String, default: "Public Ticket" },
|
||||
description: String,
|
||||
price: { type: Number, default: 0 },
|
||||
quantity: Number, // null = unlimited
|
||||
sold: { type: Number, default: 0 },
|
||||
earlyBirdPrice: Number,
|
||||
earlyBirdDeadline: Date
|
||||
}
|
||||
earlyBirdDeadline: Date,
|
||||
},
|
||||
},
|
||||
// Circle targeting
|
||||
targetCircles: [{
|
||||
type: String,
|
||||
enum: ['community', 'founder', 'practitioner'],
|
||||
required: false
|
||||
}],
|
||||
targetCircles: [
|
||||
{
|
||||
type: String,
|
||||
enum: ["community", "founder", "practitioner"],
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
maxAttendees: Number,
|
||||
registrationRequired: { type: Boolean, default: false },
|
||||
registrationDeadline: Date,
|
||||
agenda: [String],
|
||||
speakers: [{
|
||||
name: String,
|
||||
role: String,
|
||||
bio: String
|
||||
}],
|
||||
registrations: [{
|
||||
name: String,
|
||||
email: String,
|
||||
membershipLevel: String,
|
||||
isMember: { type: Boolean, default: false },
|
||||
paymentStatus: {
|
||||
type: String,
|
||||
enum: ['pending', 'completed', 'failed', 'not_required'],
|
||||
default: 'not_required'
|
||||
speakers: [
|
||||
{
|
||||
name: String,
|
||||
role: String,
|
||||
bio: String,
|
||||
},
|
||||
paymentId: String, // Helcim transaction ID
|
||||
amountPaid: { type: Number, default: 0 },
|
||||
registeredAt: { type: Date, default: Date.now }
|
||||
}],
|
||||
],
|
||||
registrations: [
|
||||
{
|
||||
memberId: { type: mongoose.Schema.Types.ObjectId, ref: "Member" }, // Reference to Member model
|
||||
name: String,
|
||||
email: String,
|
||||
membershipLevel: String,
|
||||
isMember: { type: Boolean, default: false },
|
||||
paymentStatus: {
|
||||
type: String,
|
||||
enum: ["pending", "completed", "failed", "not_required"],
|
||||
default: "not_required",
|
||||
},
|
||||
paymentId: String, // Helcim transaction ID
|
||||
amountPaid: { type: Number, default: 0 },
|
||||
registeredAt: { type: Date, default: Date.now },
|
||||
},
|
||||
],
|
||||
createdBy: { type: String, required: true },
|
||||
createdAt: { type: Date, default: Date.now },
|
||||
updatedAt: { type: Date, default: Date.now }
|
||||
})
|
||||
updatedAt: { type: Date, default: Date.now },
|
||||
});
|
||||
|
||||
// Generate slug from title
|
||||
function generateSlug(title) {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
// Pre-save hook to generate slug
|
||||
eventSchema.pre('save', async function(next) {
|
||||
eventSchema.pre("save", async function (next) {
|
||||
try {
|
||||
if (this.isNew || this.isModified('title')) {
|
||||
let baseSlug = generateSlug(this.title)
|
||||
let slug = baseSlug
|
||||
let counter = 1
|
||||
if (this.isNew || this.isModified("title")) {
|
||||
let baseSlug = generateSlug(this.title);
|
||||
let slug = baseSlug;
|
||||
let counter = 1;
|
||||
|
||||
// Ensure slug is unique
|
||||
while (await this.constructor.findOne({ slug, _id: { $ne: this._id } })) {
|
||||
slug = `${baseSlug}-${counter}`
|
||||
counter++
|
||||
slug = `${baseSlug}-${counter}`;
|
||||
counter++;
|
||||
}
|
||||
|
||||
this.slug = slug
|
||||
this.slug = slug;
|
||||
}
|
||||
next()
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('Error in pre-save hook:', error)
|
||||
next(error)
|
||||
console.error("Error in pre-save hook:", error);
|
||||
next(error);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
export default mongoose.models.Event || mongoose.model('Event', eventSchema)
|
||||
export default mongoose.models.Event || mongoose.model("Event", eventSchema);
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
// server/models/member.js
|
||||
import mongoose from 'mongoose'
|
||||
import { resolve } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import mongoose from "mongoose";
|
||||
import { resolve } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
||||
const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
||||
|
||||
// Import configs using dynamic imports to avoid build issues
|
||||
const getValidCircleValues = () => ['community', 'founder', 'practitioner']
|
||||
const getValidContributionValues = () => ['0', '5', '15', '30', '50']
|
||||
const getValidCircleValues = () => ["community", "founder", "practitioner"];
|
||||
const getValidContributionValues = () => ["0", "5", "15", "30", "50"];
|
||||
|
||||
const memberSchema = new mongoose.Schema({
|
||||
email: { type: String, required: true, unique: true },
|
||||
|
|
@ -15,32 +15,111 @@ const memberSchema = new mongoose.Schema({
|
|||
circle: {
|
||||
type: String,
|
||||
enum: getValidCircleValues(),
|
||||
required: true
|
||||
required: true,
|
||||
},
|
||||
contributionTier: {
|
||||
type: String,
|
||||
enum: getValidContributionValues(),
|
||||
required: true
|
||||
required: true,
|
||||
},
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['pending_payment', 'active', 'suspended', 'cancelled'],
|
||||
default: 'pending_payment'
|
||||
enum: ["pending_payment", "active", "suspended", "cancelled"],
|
||||
default: "pending_payment",
|
||||
},
|
||||
helcimCustomerId: String,
|
||||
helcimSubscriptionId: String,
|
||||
paymentMethod: {
|
||||
type: String,
|
||||
enum: ['card', 'bank', 'none'],
|
||||
default: 'none'
|
||||
enum: ["card", "bank", "none"],
|
||||
default: "none",
|
||||
},
|
||||
subscriptionStartDate: Date,
|
||||
subscriptionEndDate: Date,
|
||||
nextBillingDate: Date,
|
||||
slackInvited: { type: Boolean, default: false },
|
||||
slackInviteStatus: {
|
||||
type: String,
|
||||
enum: ["pending", "sent", "failed", "accepted"],
|
||||
default: "pending",
|
||||
},
|
||||
slackUserId: String,
|
||||
|
||||
// Profile fields
|
||||
pronouns: String,
|
||||
timeZone: String,
|
||||
avatar: String,
|
||||
studio: String,
|
||||
bio: String,
|
||||
skills: [String],
|
||||
location: String,
|
||||
socialLinks: {
|
||||
mastodon: String,
|
||||
linkedin: String,
|
||||
website: String,
|
||||
other: String,
|
||||
},
|
||||
offering: String,
|
||||
lookingFor: String,
|
||||
showInDirectory: { type: Boolean, default: true },
|
||||
|
||||
// Privacy settings for profile fields
|
||||
privacy: {
|
||||
pronouns: {
|
||||
type: String,
|
||||
enum: ["public", "members", "private"],
|
||||
default: "members",
|
||||
},
|
||||
timeZone: {
|
||||
type: String,
|
||||
enum: ["public", "members", "private"],
|
||||
default: "members",
|
||||
},
|
||||
avatar: {
|
||||
type: String,
|
||||
enum: ["public", "members", "private"],
|
||||
default: "public",
|
||||
},
|
||||
studio: {
|
||||
type: String,
|
||||
enum: ["public", "members", "private"],
|
||||
default: "members",
|
||||
},
|
||||
bio: {
|
||||
type: String,
|
||||
enum: ["public", "members", "private"],
|
||||
default: "members",
|
||||
},
|
||||
skills: {
|
||||
type: String,
|
||||
enum: ["public", "members", "private"],
|
||||
default: "members",
|
||||
},
|
||||
location: {
|
||||
type: String,
|
||||
enum: ["public", "members", "private"],
|
||||
default: "members",
|
||||
},
|
||||
socialLinks: {
|
||||
type: String,
|
||||
enum: ["public", "members", "private"],
|
||||
default: "members",
|
||||
},
|
||||
offering: {
|
||||
type: String,
|
||||
enum: ["public", "members", "private"],
|
||||
default: "members",
|
||||
},
|
||||
lookingFor: {
|
||||
type: String,
|
||||
enum: ["public", "members", "private"],
|
||||
default: "members",
|
||||
},
|
||||
},
|
||||
|
||||
createdAt: { type: Date, default: Date.now },
|
||||
lastLogin: Date
|
||||
})
|
||||
lastLogin: Date,
|
||||
});
|
||||
|
||||
// Check if model already exists to prevent re-compilation in development
|
||||
export default mongoose.models.Member || mongoose.model('Member', memberSchema)
|
||||
export default mongoose.models.Member || mongoose.model("Member", memberSchema);
|
||||
|
|
|
|||
50
server/models/update.js
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import mongoose from "mongoose";
|
||||
|
||||
const updateSchema = new mongoose.Schema({
|
||||
author: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: "Member",
|
||||
required: true,
|
||||
},
|
||||
content: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
images: [
|
||||
{
|
||||
url: String,
|
||||
publicId: String,
|
||||
alt: String,
|
||||
},
|
||||
],
|
||||
privacy: {
|
||||
type: String,
|
||||
enum: ["public", "members", "private"],
|
||||
default: "members",
|
||||
},
|
||||
commentsEnabled: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now,
|
||||
},
|
||||
updatedAt: {
|
||||
type: Date,
|
||||
default: Date.now,
|
||||
},
|
||||
});
|
||||
|
||||
// Update the updatedAt timestamp on save
|
||||
updateSchema.pre("save", function (next) {
|
||||
this.updatedAt = Date.now();
|
||||
next();
|
||||
});
|
||||
|
||||
// Indexes for performance
|
||||
updateSchema.index({ createdAt: -1 }); // For sorting by date
|
||||
updateSchema.index({ privacy: 1, createdAt: -1 }); // Compound index for filtering and sorting
|
||||
updateSchema.index({ author: 1 }); // For author lookups
|
||||
|
||||
export default mongoose.models.Update || mongoose.model("Update", updateSchema);
|
||||
233
server/utils/slack.ts
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
import { WebClient } from "@slack/web-api";
|
||||
|
||||
export class SlackService {
|
||||
private client: WebClient;
|
||||
private vettingChannelId: string;
|
||||
|
||||
constructor(botToken: string, vettingChannelId: string) {
|
||||
this.client = new WebClient(botToken);
|
||||
this.vettingChannelId = vettingChannelId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invite user to workspace and channel (using proper admin and conversation scopes)
|
||||
*/
|
||||
async inviteUserToSlack(
|
||||
email: string,
|
||||
realName: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
userId?: string;
|
||||
status?: string;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// First, check if user already exists in workspace
|
||||
const existingUser = await this.findUserByEmail(email);
|
||||
|
||||
if (existingUser) {
|
||||
// User exists, invite them to the vetting channel
|
||||
try {
|
||||
await this.client.conversations.invite({
|
||||
channel: this.vettingChannelId,
|
||||
users: existingUser,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`Successfully invited existing user ${email} to vetting channel`
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
userId: existingUser,
|
||||
status: "existing_user_added_to_channel",
|
||||
};
|
||||
} catch (error: any) {
|
||||
if (error.data?.error === "already_in_channel") {
|
||||
return {
|
||||
success: true,
|
||||
userId: existingUser,
|
||||
status: "user_already_in_channel",
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// User doesn't exist, try to invite to workspace using admin API
|
||||
try {
|
||||
const inviteResponse = await this.client.admin.users.invite({
|
||||
email: email,
|
||||
real_name: realName,
|
||||
channel_ids: [this.vettingChannelId],
|
||||
is_restricted: true, // Single-channel guest
|
||||
is_ultra_restricted: false,
|
||||
});
|
||||
|
||||
if (inviteResponse.ok && inviteResponse.user) {
|
||||
console.log(
|
||||
`Successfully invited ${email} to workspace as single-channel guest`
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
userId: inviteResponse.user.id,
|
||||
status: "new_user_invited_to_workspace",
|
||||
};
|
||||
} else {
|
||||
throw new Error(`Admin invite failed: ${inviteResponse.error}`);
|
||||
}
|
||||
} catch (adminError: any) {
|
||||
console.log(
|
||||
`Admin API not available or failed: ${
|
||||
adminError.data?.error || adminError.message
|
||||
}`
|
||||
);
|
||||
|
||||
// Fall back to manual process
|
||||
return {
|
||||
success: true,
|
||||
status: "manual_invitation_required",
|
||||
error: `Admin API unavailable: ${
|
||||
adminError.data?.error || adminError.message
|
||||
}`,
|
||||
};
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`Failed to process invitation for ${email}:`, error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error.data?.error || error.message || "Unknown error occurred",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find user in workspace by email
|
||||
*/
|
||||
private async findUserByEmail(email: string): Promise<string | null> {
|
||||
try {
|
||||
const response = await this.client.users.lookupByEmail({ email });
|
||||
return response.user?.id || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a notification to the vetting channel about a new member
|
||||
*/
|
||||
async notifyNewMember(
|
||||
memberName: string,
|
||||
memberEmail: string,
|
||||
circle: string,
|
||||
contributionTier: string,
|
||||
invitationStatus: string = "manual_invitation_required"
|
||||
): Promise<void> {
|
||||
try {
|
||||
let statusMessage = "";
|
||||
let actionMessage = "";
|
||||
|
||||
switch (invitationStatus) {
|
||||
case "existing_user_added_to_channel":
|
||||
statusMessage =
|
||||
"✅ Existing user automatically added to this channel.";
|
||||
actionMessage = "Ready for vetting!";
|
||||
break;
|
||||
case "user_already_in_channel":
|
||||
statusMessage = "✅ User is already in this channel.";
|
||||
actionMessage = "Ready for vetting!";
|
||||
break;
|
||||
case "new_user_invited_to_workspace":
|
||||
statusMessage =
|
||||
"🎉 User successfully invited to workspace as single-channel guest.";
|
||||
actionMessage = "Ready for vetting!";
|
||||
break;
|
||||
case "manual_invitation_required":
|
||||
statusMessage = "📧 User needs to be manually invited to join Slack.";
|
||||
actionMessage = `Please vet this new member before inviting them to other channels.`;
|
||||
break;
|
||||
default:
|
||||
statusMessage = "⚠️ Invitation status unknown.";
|
||||
actionMessage = "Manual review required.";
|
||||
}
|
||||
|
||||
await this.client.chat.postMessage({
|
||||
channel: this.vettingChannelId,
|
||||
text: `New Ghost Guild member: ${memberName}`,
|
||||
blocks: [
|
||||
{
|
||||
type: "header",
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: "New Ghost Guild Member Registration",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "section",
|
||||
fields: [
|
||||
{
|
||||
type: "mrkdwn",
|
||||
text: `*Name:*\n${memberName}`,
|
||||
},
|
||||
{
|
||||
type: "mrkdwn",
|
||||
text: `*Email:*\n${memberEmail}`,
|
||||
},
|
||||
{
|
||||
type: "mrkdwn",
|
||||
text: `*Circle:*\n${circle}`,
|
||||
},
|
||||
{
|
||||
type: "mrkdwn",
|
||||
text: `*Contribution:*\n$${contributionTier}/month`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: `*Status:* ${statusMessage}\n*Action:* ${actionMessage}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "divider",
|
||||
},
|
||||
],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to send Slack notification:", error);
|
||||
// Don't throw - this is non-critical
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the Slack channel exists and bot has access
|
||||
*/
|
||||
async verifyChannelAccess(): Promise<boolean> {
|
||||
try {
|
||||
const response = await this.client.conversations.info({
|
||||
channel: this.vettingChannelId,
|
||||
});
|
||||
return response.ok && !!response.channel;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configured Slack service instance
|
||||
*/
|
||||
export function getSlackService(): SlackService | null {
|
||||
const config = useRuntimeConfig();
|
||||
|
||||
if (!config.slackBotToken || !config.slackVettingChannelId) {
|
||||
console.warn(
|
||||
"Slack integration not configured - missing bot token or channel ID"
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return new SlackService(config.slackBotToken, config.slackVettingChannelId);
|
||||
}
|
||||
30
slack-app-manifest.yaml
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
display_information:
|
||||
name: Ghost Guild Member Bot
|
||||
description: Automatically invites new Ghost Guild members to Slack as single-channel guests for vetting
|
||||
background_color: "#2c2d30"
|
||||
long_description: This bot automatically invites newly registered Ghost Guild members to a designated vetting channel as single-channel guests. Staff can then manually verify and upgrade members as needed. The bot also sends notifications about new members to help with the vetting process.
|
||||
|
||||
features:
|
||||
bot_user:
|
||||
display_name: Ghost Guild Member Bot
|
||||
always_online: false
|
||||
|
||||
oauth_config:
|
||||
scopes:
|
||||
bot:
|
||||
- channels:read # To verify channel access
|
||||
- channels:join # To join channels if needed
|
||||
- chat:write # To send notifications to vetting channel
|
||||
- chat:write.public # To post in public channels
|
||||
- users:read # To get user information
|
||||
- users:read.email # To match users by email
|
||||
- conversations:write.invites # Modern scope for inviting to channels
|
||||
- conversations:history # To read channel messages
|
||||
- admin.users:write # To invite users to workspace
|
||||
- admin.conversations:write # To manage conversations administratively
|
||||
- im:write # To send direct messages
|
||||
|
||||
settings:
|
||||
org_deploy_enabled: false
|
||||
socket_mode_enabled: false
|
||||
token_rotation_enabled: false
|
||||