Many an update!

This commit is contained in:
Jennie Robinson Faber 2025-12-01 15:26:42 +00:00
parent 85195d6c7a
commit d588c49946
35 changed files with 3528 additions and 1142 deletions

2
.gitignore vendored
View file

@ -22,3 +22,5 @@ logs
.env .env
.env.* .env.*
!.env.example !.env.example
/*.md
scripts/*.js

View file

@ -3,5 +3,6 @@
<NuxtLayout> <NuxtLayout>
<NuxtPage /> <NuxtPage />
</NuxtLayout> </NuxtLayout>
<LoginModal />
</UApp> </UApp>
</template> </template>

View file

@ -0,0 +1,201 @@
<template>
<UModal
v-model:open="isOpen"
:title="title"
:description="description"
:dismissible="dismissible"
:ui="{
content: 'bg-ghost-900 border border-ghost-700',
header: 'bg-ghost-900 border-b border-ghost-700',
body: 'bg-ghost-900',
footer: 'bg-ghost-900 border-t border-ghost-700',
title: 'text-ghost-100',
description: 'text-ghost-400',
}"
>
<template #body>
<div class="space-y-6">
<!-- Success State -->
<div v-if="loginSuccess" class="text-center py-4">
<div class="w-16 h-16 bg-green-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
<Icon name="heroicons:check-circle" class="w-10 h-10 text-green-400" />
</div>
<h3 class="text-lg font-semibold text-ghost-100 mb-2">Check your email</h3>
<p class="text-ghost-300">
We've sent a magic link to <strong class="text-ghost-100">{{ loginForm.email }}</strong>.
Click the link to sign in.
</p>
</div>
<!-- Login Form -->
<UForm v-else :state="loginForm" @submit="handleLogin">
<UFormField label="Email Address" name="email" required class="mb-4">
<UInput
v-model="loginForm.email"
type="email"
size="lg"
class="w-full"
placeholder="your.email@example.com"
:ui="{
root: 'bg-ghost-800 border-ghost-600 text-ghost-100 placeholder-ghost-500',
}"
/>
</UFormField>
<!-- Info Box -->
<div class="bg-ghost-800 border border-ghost-600 p-4 rounded-lg mb-6">
<div class="flex items-start gap-3">
<Icon name="heroicons:envelope" class="w-5 h-5 text-whisper-400 flex-shrink-0 mt-0.5" />
<p class="text-sm text-ghost-300">
We'll send you a secure magic link. No password needed!
</p>
</div>
</div>
<!-- Error Message -->
<div
v-if="loginError"
class="mb-4 p-3 bg-red-500/10 border border-red-500/30 rounded-lg"
>
<p class="text-red-400 text-sm">{{ loginError }}</p>
</div>
<!-- Submit Button -->
<UButton
type="submit"
:loading="isLoggingIn"
:disabled="!isLoginFormValid"
size="lg"
class="w-full"
>
Send Magic Link
</UButton>
</UForm>
<!-- Join Link -->
<div v-if="!loginSuccess" class="text-center pt-2 border-t border-ghost-700">
<p class="text-ghost-400 text-sm pt-4">
Don't have an account?
<NuxtLink
to="/join"
class="text-whisper-400 hover:text-whisper-300 font-medium"
@click="close"
>
Join Ghost Guild
</NuxtLink>
</p>
</div>
</div>
</template>
<template #footer>
<div class="flex justify-end">
<UButton
v-if="loginSuccess"
variant="ghost"
color="neutral"
@click="resetAndClose"
>
Close
</UButton>
</div>
</template>
</UModal>
</template>
<script setup>
const props = defineProps({
title: {
type: String,
default: 'Sign in to continue',
},
description: {
type: String,
default: 'Enter your email to receive a secure login link',
},
dismissible: {
type: Boolean,
default: true,
},
})
const emit = defineEmits(['success', 'close'])
const { showLoginModal, hideLoginModal } = useLoginModal()
const { checkMemberStatus } = useAuth()
const isOpen = computed({
get: () => showLoginModal.value,
set: (value) => {
if (!value) {
hideLoginModal()
emit('close')
}
},
})
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 = ''
try {
const response = await $fetch('/api/auth/login', {
method: 'POST',
body: {
email: loginForm.email,
},
})
if (response.success) {
loginSuccess.value = true
emit('success', { email: loginForm.email })
}
} catch (err) {
console.error('Login error:', err)
if (err.statusCode === 404) {
loginError.value = 'No account found with that email. Please check your email or join Ghost Guild.'
} else if (err.statusCode === 500) {
loginError.value = 'Failed to send login email. Please try again later.'
} else {
loginError.value = err.statusMessage || 'Something went wrong. Please try again.'
}
} finally {
isLoggingIn.value = false
}
}
const close = () => {
isOpen.value = false
}
const resetAndClose = () => {
loginForm.email = ''
loginSuccess.value = false
loginError.value = ''
close()
}
// Reset form when modal opens
watch(isOpen, (newValue) => {
if (newValue) {
loginForm.email = ''
loginSuccess.value = false
loginError.value = ''
}
})
</script>

View file

@ -0,0 +1,126 @@
<template>
<div v-if="shouldShowBanner" class="w-full">
<div
:class="[
'backdrop-blur-sm border rounded-lg p-4 flex items-start gap-4',
statusConfig.bgColor,
statusConfig.borderColor,
]"
>
<Icon
:name="statusConfig.icon"
:class="['w-5 h-5 flex-shrink-0 mt-0.5', statusConfig.textColor]"
/>
<div class="flex-1 min-w-0">
<h3 :class="['font-semibold mb-1', statusConfig.textColor]">
{{ statusConfig.label }}
</h3>
<p :class="['text-sm', statusConfig.textColor, 'opacity-90']">
{{ bannerMessage }}
</p>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<!-- Payment button for pending payment status -->
<UButton
v-if="isPendingPayment && nextAction"
:color="getButtonColor(nextAction.color)"
size="sm"
:loading="isProcessingPayment"
@click="handleActionClick"
class="whitespace-nowrap"
>
{{ isProcessingPayment ? "Processing..." : nextAction.label }}
</UButton>
<!-- Link button for other actions -->
<NuxtLink
v-else-if="nextAction && nextAction.link"
:to="nextAction.link"
:class="[
'px-4 py-2 rounded-lg font-medium text-sm whitespace-nowrap transition-all',
getActionButtonClass(nextAction.color),
]"
>
{{ nextAction.label }}
</NuxtLink>
<button
v-if="dismissible"
@click="isDismissed = true"
class="text-ghost-400 hover:text-ghost-200 transition-colors"
:aria-label="`Dismiss ${statusConfig.label} banner`"
>
<Icon name="heroicons:x-mark" class="w-5 h-5" />
</button>
</div>
</div>
</div>
</template>
<script setup>
const props = defineProps({
dismissible: {
type: Boolean,
default: true,
},
compact: {
type: Boolean,
default: false,
},
});
const {
isPendingPayment,
isSuspended,
isCancelled,
statusConfig,
getNextAction,
getBannerMessage,
} = useMemberStatus();
const { completePayment, isProcessingPayment } = useMemberPayment();
const isDismissed = ref(false);
// Handle action button click
const handleActionClick = async () => {
if (isPendingPayment.value) {
try {
await completePayment();
} catch (error) {
console.error("Payment failed:", error);
}
}
};
// Map color names to UButton color props
const getButtonColor = (color) => {
const colorMap = {
orange: "orange",
blue: "blue",
gray: "gray",
};
return colorMap[color] || "blue";
};
// Only show banner if status is not active
const shouldShowBanner = computed(() => {
if (isDismissed.value) return false;
return isPendingPayment.value || isSuspended.value || isCancelled.value;
});
const bannerMessage = computed(() => getBannerMessage());
const nextAction = computed(() => getNextAction());
// Button styling based on color
const getActionButtonClass = (color) => {
const baseClass = "hover:scale-105 active:scale-95";
const colorClasses = {
orange: "bg-orange-600 text-white hover:bg-orange-700",
blue: "bg-blue-600 text-white hover:bg-blue-700",
gray: "bg-ghost-700 text-ghost-100 hover:bg-ghost-600",
};
return `${baseClass} ${colorClasses[color] || colorClasses.blue}`;
};
</script>

View file

@ -0,0 +1,13 @@
<template>
<div v-if="!isActive" class="inline-flex items-center gap-1">
<Icon
:name="statusConfig.icon"
:class="['w-4 h-4', statusConfig.textColor]"
:title="`Status: ${statusConfig.label}`"
/>
</div>
</template>
<script setup>
const { isActive, statusConfig } = useMemberStatus()
</script>

View file

@ -0,0 +1,83 @@
// Composable for managing calendar search and filter state
export const useCalendarSearch = () => {
const searchQuery = ref("");
const includePastEvents = ref(false);
const searchResults = ref([]);
const selectedCategories = ref([]);
const isSearching = computed(() => {
return (
searchQuery.value.length > 0 ||
selectedCategories.value.length > 0
);
});
// Save search state to sessionStorage for persistence across navigation
const saveSearchState = (
query,
categories,
includePast,
results
) => {
if (process.client) {
sessionStorage.setItem(
"calendarSearchState",
JSON.stringify({
searchQuery: query,
selectedCategories: categories,
includePastEvents: includePast,
searchResults: results,
timestamp: Date.now(),
})
);
}
};
// Load search state from sessionStorage
const loadSearchState = () => {
if (process.client) {
const saved = sessionStorage.getItem("calendarSearchState");
if (saved) {
try {
const state = JSON.parse(saved);
// Only restore if saved less than 30 minutes ago
if (Date.now() - state.timestamp < 30 * 60 * 1000) {
searchQuery.value = state.searchQuery;
selectedCategories.value = state.selectedCategories;
includePastEvents.value = state.includePastEvents;
searchResults.value = state.searchResults;
return true;
}
} catch (e) {
console.error("Failed to load search state:", e);
}
}
}
return false;
};
// Clear all search filters
const clearSearch = () => {
searchQuery.value = "";
selectedCategories.value = [];
searchResults.value = [];
};
// Clear search state from sessionStorage
const clearSearchState = () => {
if (process.client) {
sessionStorage.removeItem("calendarSearchState");
}
};
return {
searchQuery,
includePastEvents,
searchResults,
selectedCategories,
isSearching,
saveSearchState,
loadSearchState,
clearSearch,
clearSearchState,
};
};

View file

@ -0,0 +1,89 @@
// Utility composable for event date handling with timezone support
export const useEventDateUtils = () => {
const TIMEZONE = "America/Toronto";
// Format a date to a specific format
const formatDate = (date, options = {}) => {
const dateObj = date instanceof Date ? date : new Date(date);
const { month = "short", day = "numeric", year = "numeric" } = options;
return new Intl.DateTimeFormat("en-US", {
month,
day,
year,
}).format(dateObj);
};
// Format event date range
const formatDateRange = (startDate, endDate, compact = false) => {
if (!startDate || !endDate) return "No dates";
const start = new Date(startDate);
const end = new Date(endDate);
const startMonth = start.toLocaleDateString("en-US", { month: "short" });
const endMonth = end.toLocaleDateString("en-US", { month: "short" });
const startDay = start.getDate();
const endDay = end.getDate();
const year = end.getFullYear();
if (compact) {
if (
start.getMonth() === end.getMonth() &&
start.getFullYear() === end.getFullYear()
) {
return `${startMonth} ${startDay}-${endDay}`;
}
return `${startMonth} ${startDay} - ${endMonth} ${endDay}`;
}
if (
start.getMonth() === end.getMonth() &&
start.getFullYear() === end.getFullYear()
) {
return `${startMonth} ${startDay}-${endDay}, ${year}`;
} else if (start.getFullYear() === end.getFullYear()) {
return `${startMonth} ${startDay} - ${endMonth} ${endDay}, ${year}`;
} else {
return `${formatDate(startDate)} - ${formatDate(endDate)}`;
}
};
// Check if a date is in the past
const isPastDate = (date) => {
const dateObj = date instanceof Date ? date : new Date(date);
const now = new Date();
return dateObj < now;
};
// Check if a date is today
const isToday = (date) => {
const dateObj = date instanceof Date ? date : new Date(date);
const today = new Date();
return (
dateObj.getDate() === today.getDate() &&
dateObj.getMonth() === today.getMonth() &&
dateObj.getFullYear() === today.getFullYear()
);
};
// Get a readable time string
const formatTime = (date, includeSeconds = false) => {
const dateObj = date instanceof Date ? date : new Date(date);
const options = {
hour: "2-digit",
minute: "2-digit",
...(includeSeconds && { second: "2-digit" }),
};
return new Intl.DateTimeFormat("en-US", options).format(dateObj);
};
return {
TIMEZONE,
formatDate,
formatDateRange,
isPastDate,
isToday,
formatTime,
};
};

View file

@ -77,17 +77,25 @@ export const useHelcimPay = () => {
return; return;
} }
// Add custom CSS to fix Helcim overlay styling // Add CSS to style Helcim overlay - needs to target the actual elements Helcim creates
// Helcim injects a div with inline styles directly into body
if (!document.getElementById("helcim-overlay-fix")) { if (!document.getElementById("helcim-overlay-fix")) {
const style = document.createElement("style"); const style = document.createElement("style");
style.id = "helcim-overlay-fix"; style.id = "helcim-overlay-fix";
style.textContent = ` style.textContent = `
/* Fix Helcim iframe overlay - the second parameter to appendHelcimPayIframe controls this */ /* Style any fixed position div that Helcim creates */
/* Target all fixed position divs that might be the overlay */ body > div[style] {
body > div[style*="position: fixed"][style*="inset: 0"],
body > div[style*="position:fixed"][style*="inset:0"] {
background-color: rgba(0, 0, 0, 0.75) !important; background-color: rgba(0, 0, 0, 0.75) !important;
} }
/* Target specifically iframes from Helcim */
body > div[style] > iframe[src*="helcim"],
body > div[style] iframe[src*="secure.helcim.app"] {
background: white !important;
border: none !important;
border-radius: 8px !important;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3) !important;
}
`; `;
document.head.appendChild(style); document.head.appendChild(style);
} }
@ -186,6 +194,41 @@ export const useHelcimPay = () => {
// Add event listener // Add event listener
window.addEventListener("message", handleHelcimPayEvent); window.addEventListener("message", handleHelcimPayEvent);
// Set up a MutationObserver to fix Helcim's overlay styling immediately
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === 1 && node.tagName === "DIV") {
const computedStyle = window.getComputedStyle(node);
// Check if this is Helcim's overlay (fixed position, full screen)
if (
computedStyle.position === "fixed" &&
computedStyle.inset === "0px"
) {
// Fix the background to show semi-transparent overlay
node.style.setProperty(
"background-color",
"rgba(0, 0, 0, 0.75)",
"important",
);
// Ensure proper centering
node.style.setProperty("display", "flex", "important");
node.style.setProperty("align-items", "center", "important");
node.style.setProperty(
"justify-content",
"center",
"important",
);
observer.disconnect(); // Stop observing once we've found and fixed it
}
}
});
});
});
// Start observing body for child additions
observer.observe(document.body, { childList: true });
// Open the HelcimPay iframe modal // Open the HelcimPay iframe modal
console.log("Calling appendHelcimPayIframe with token:", checkoutToken); console.log("Calling appendHelcimPayIframe with token:", checkoutToken);
window.appendHelcimPayIframe(checkoutToken, true); window.appendHelcimPayIframe(checkoutToken, true);
@ -193,6 +236,9 @@ export const useHelcimPay = () => {
"appendHelcimPayIframe called, waiting for window messages...", "appendHelcimPayIframe called, waiting for window messages...",
); );
// Clean up observer after a timeout
setTimeout(() => observer.disconnect(), 5000);
// Add timeout to clean up if no response // Add timeout to clean up if no response
setTimeout(() => { setTimeout(() => {
console.log("60 seconds passed, cleaning up event listener..."); console.log("60 seconds passed, cleaning up event listener...");

View file

@ -0,0 +1,30 @@
export const useLoginModal = () => {
const showLoginModal = useState('loginModal.show', () => false)
const loginModalOptions = useState('loginModal.options', () => ({
title: 'Sign in to continue',
description: 'Enter your email to receive a secure login link',
dismissible: true,
redirectTo: null,
}))
const openLoginModal = (options = {}) => {
loginModalOptions.value = {
title: options.title || 'Sign in to continue',
description: options.description || 'Enter your email to receive a secure login link',
dismissible: options.dismissible !== false,
redirectTo: options.redirectTo || null,
}
showLoginModal.value = true
}
const hideLoginModal = () => {
showLoginModal.value = false
}
return {
showLoginModal: readonly(showLoginModal),
loginModalOptions: readonly(loginModalOptions),
openLoginModal,
hideLoginModal,
}
}

View file

@ -0,0 +1,166 @@
/**
* Member Payment Management Composable
* Handles payment setup and subscription creation for pending payment members
*/
export const useMemberPayment = () => {
const { memberData, checkMemberStatus } = useAuth()
const { initializeHelcimPay, verifyPayment, cleanup: cleanupHelcimPay } =
useHelcimPay()
const isProcessingPayment = ref(false)
const paymentError = ref(null)
const paymentSuccess = ref(false)
const customerId = ref('')
const customerCode = ref('')
/**
* Initiate payment setup for a member with pending_payment status
* This is the main entry point called from "Complete Payment" buttons
*/
const initiatePaymentSetup = async () => {
isProcessingPayment.value = true
paymentError.value = null
paymentSuccess.value = false
try {
// Step 1: Get or create Helcim customer
await getOrCreateCustomer()
// Step 2: Initialize Helcim payment with $0 for card verification
await initializeHelcimPay(
customerId.value,
customerCode.value,
0,
)
// Step 3: Show payment modal and get payment result
const paymentResult = await verifyPayment()
console.log('Payment result:', paymentResult)
if (!paymentResult.success) {
throw new Error('Payment verification failed')
}
// Step 4: Verify payment on backend
const verifyResult = await $fetch('/api/helcim/verify-payment', {
method: 'POST',
body: {
cardToken: paymentResult.cardToken,
customerId: customerId.value,
},
})
if (!verifyResult.success) {
throw new Error('Payment verification failed on backend')
}
// Step 5: Create subscription with proper contribution tier
const subscriptionResponse = await $fetch('/api/helcim/subscription', {
method: 'POST',
body: {
customerId: customerId.value,
customerCode: customerCode.value,
contributionTier: memberData.value?.contributionTier || '5',
cardToken: paymentResult.cardToken,
},
})
if (!subscriptionResponse.success) {
throw new Error('Subscription creation failed')
}
// Step 6: Payment successful - refresh member data
paymentSuccess.value = true
await checkMemberStatus()
// Clear success message after 3 seconds
setTimeout(() => {
paymentSuccess.value = false
}, 3000)
return {
success: true,
message: 'Payment completed successfully!',
}
} catch (error) {
console.error('Payment setup error:', error)
paymentError.value =
error.message || 'Payment setup failed. Please try again.'
throw error
} finally {
isProcessingPayment.value = false
cleanupHelcimPay()
}
}
/**
* Get or create Helcim customer for member
*/
const getOrCreateCustomer = async () => {
try {
if (!memberData.value?.helcimCustomerId) {
// Create new customer
const customerResponse = await $fetch(
'/api/helcim/get-or-create-customer',
{
method: 'POST',
},
)
customerId.value = customerResponse.customerId
customerCode.value = customerResponse.customerCode
console.log(
'Created new Helcim customer:',
customerId.value,
)
} else {
// Get customer code from existing customer
const customerResponse = await $fetch(
'/api/helcim/customer-code',
)
customerId.value = customerResponse.customerId
customerCode.value = customerResponse.customerCode
console.log(
'Using existing Helcim customer:',
customerId.value,
)
}
} catch (error) {
console.error('Failed to get or create customer:', error)
throw new Error('Failed to initialize payment. Please try again.')
}
}
/**
* Complete payment from status banner
* Entry point for clicking "Complete Payment" from any page
*/
const completePayment = async () => {
try {
await initiatePaymentSetup()
return true
} catch (error) {
console.error('Payment failed:', error)
return false
}
}
const resetPaymentState = () => {
isProcessingPayment.value = false
paymentError.value = null
paymentSuccess.value = false
}
return {
isProcessingPayment: readonly(isProcessingPayment),
paymentError: readonly(paymentError),
paymentSuccess: readonly(paymentSuccess),
initiatePaymentSetup,
completePayment,
resetPaymentState,
}
}

View file

@ -0,0 +1,155 @@
/**
* Member Status Management Composable
* Handles member status constants, helpers, and UI state
*/
export const MEMBER_STATUSES = {
PENDING_PAYMENT: 'pending_payment',
ACTIVE: 'active',
SUSPENDED: 'suspended',
CANCELLED: 'cancelled',
}
export const MEMBER_STATUS_CONFIG = {
pending_payment: {
label: 'Payment Pending',
color: 'orange',
bgColor: 'bg-orange-500/10',
borderColor: 'border-orange-500/30',
textColor: 'text-orange-300',
icon: 'heroicons:exclamation-triangle',
severity: 'warning',
canRSVP: false,
canAccessMembers: true,
canPeerSupport: false,
},
active: {
label: 'Active Member',
color: 'green',
bgColor: 'bg-green-500/10',
borderColor: 'border-green-500/30',
textColor: 'text-green-300',
icon: 'heroicons:check-circle',
severity: 'success',
canRSVP: true,
canAccessMembers: true,
canPeerSupport: true,
},
suspended: {
label: 'Membership Suspended',
color: 'red',
bgColor: 'bg-red-500/10',
borderColor: 'border-red-500/30',
textColor: 'text-red-300',
icon: 'heroicons:no-symbol',
severity: 'error',
canRSVP: false,
canAccessMembers: false,
canPeerSupport: false,
},
cancelled: {
label: 'Membership Cancelled',
color: 'gray',
bgColor: 'bg-gray-500/10',
borderColor: 'border-gray-500/30',
textColor: 'text-gray-300',
icon: 'heroicons:x-circle',
severity: 'error',
canRSVP: false,
canAccessMembers: false,
canPeerSupport: false,
},
}
export const useMemberStatus = () => {
const { memberData } = useAuth()
// Get current member status
const status = computed(() => memberData.value?.status || MEMBER_STATUSES.PENDING_PAYMENT)
// Get status configuration
const statusConfig = computed(() => MEMBER_STATUS_CONFIG[status.value] || MEMBER_STATUS_CONFIG.pending_payment)
// Helper methods
const isActive = computed(() => status.value === MEMBER_STATUSES.ACTIVE)
const isPendingPayment = computed(() => status.value === MEMBER_STATUSES.PENDING_PAYMENT)
const isSuspended = computed(() => status.value === MEMBER_STATUSES.SUSPENDED)
const isCancelled = computed(() => status.value === MEMBER_STATUSES.CANCELLED)
const isInactive = computed(() => !isActive.value)
// Check if member can perform action
const canRSVP = computed(() => statusConfig.value.canRSVP)
const canAccessMembers = computed(() => statusConfig.value.canAccessMembers)
const canPeerSupport = computed(() => statusConfig.value.canPeerSupport)
// Get action button text and link based on status
const getNextAction = () => {
if (isPendingPayment.value) {
return {
label: 'Complete Payment',
link: '/member/profile#account',
icon: 'heroicons:credit-card',
color: 'orange',
}
}
if (isCancelled.value) {
return {
label: 'Reactivate Membership',
link: '/member/profile#account',
icon: 'heroicons:arrow-path',
color: 'blue',
}
}
if (isSuspended.value) {
return {
label: 'Contact Support',
link: 'mailto:support@ghostguild.org',
icon: 'heroicons:envelope',
color: 'gray',
}
}
return null
}
// Get banner message based on status
const getBannerMessage = () => {
if (isPendingPayment.value) {
return 'Your membership is pending payment. Please complete your payment to unlock full features.'
}
if (isSuspended.value) {
return 'Your membership has been suspended. Please contact support to reactivate your account.'
}
if (isCancelled.value) {
return 'Your membership has been cancelled. Would you like to reactivate?'
}
return null
}
// Get RSVP restriction message
const getRSVPMessage = () => {
if (isPendingPayment.value) {
return 'Complete your payment to register for events'
}
if (isSuspended.value || isCancelled.value) {
return 'Your membership status prevents RSVP. Please reactivate your account.'
}
return null
}
return {
status,
statusConfig,
isActive,
isPendingPayment,
isSuspended,
isCancelled,
isInactive,
canRSVP,
canAccessMembers,
canPeerSupport,
getNextAction,
getBannerMessage,
getRSVPMessage,
MEMBER_STATUSES,
}
}

View file

@ -342,7 +342,7 @@ const vClickOutside = {
const logout = async () => { const logout = async () => {
try { try {
await $fetch("/api/auth/logout", { method: "POST" }); await $fetch("/api/auth/logout", { method: "POST" });
await navigateTo("/login"); await navigateTo("/");
} catch (error) { } catch (error) {
console.error("Logout failed:", error); console.error("Logout failed:", error);
} }

View file

@ -6,6 +6,7 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
} }
const { memberData, checkMemberStatus } = useAuth() const { memberData, checkMemberStatus } = useAuth()
const { openLoginModal } = useLoginModal()
console.log('🛡️ Auth middleware (CLIENT) - route:', to.path) console.log('🛡️ Auth middleware (CLIENT) - route:', to.path)
console.log(' - memberData exists:', !!memberData.value) console.log(' - memberData exists:', !!memberData.value)
@ -18,8 +19,16 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
console.log(' - Authentication result:', isAuthenticated) console.log(' - Authentication result:', isAuthenticated)
if (!isAuthenticated) { if (!isAuthenticated) {
console.log(' - ❌ Authentication failed, redirecting to login') console.log(' - ❌ Authentication failed, showing login modal')
return navigateTo('/login') // Open login modal instead of redirecting
openLoginModal({
title: 'Sign in to continue',
description: 'You need to be signed in to access this page',
dismissible: true,
redirectTo: to.fullPath,
})
// Abort navigation - stay on current page with modal open
return abortNavigation()
} }
} }

View file

@ -2,206 +2,63 @@
<div> <div>
<!-- Page Header --> <!-- Page Header -->
<PageHeader <PageHeader
title="About Our Membership Circles" title="About Ghost Guild"
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." subtitle=""
theme="blue" theme="blue"
size="large" size="large"
/> />
<!-- How Ghost Guild Works --> <!-- Main Content -->
<section class="py-20 bg-[--ui-bg]"> <section class="py-20 bg-[--ui-bg]">
<UContainer> <UContainer>
<div class="max-w-4xl mx-auto"> <div class="max-w-3xl">
<h2 class="text-3xl font-bold text-[--ui-text] mb-6"> <!-- TODO: Add copy about history and connection to Baby Ghosts -->
How Ghost Guild Works <p class="text-lg text-[--ui-text-muted]">
</h2> Ghost Guild is a community of learning and practice for anyone,
anywhere interested in a video game industry-wide shift to
<div class="prose prose-lg dark:prose-invert max-w-none"> worker-owned studio models. It is also the membership program of
<p class="text-xl font-semibold text-[--ui-text] mb-6"> Baby Ghosts, a Canadian nonprofit that provides resources and
Everyone gets everything. Your circle reflects where you are in support for worker-owned studios. After running our Peer Accelerator
your cooperative journey. Your financial contribution reflects program for three years, we are now scaling up our operations to
what you can afford. These are completely separate choices. support more studios and expand our reach. As we build our knowledge
commons, more folks unable to participate in the program can benefit
from collectively compiled knowledge and find community.
</p>
<p>
something here about the work to make Slack integration smooth and
safe; more about purpose??
</p>
<p>
We are pretty interested in saying _fuck you_ to hierarchy however
it shows up in our work. So the Ghost Guild membership program is
tier-less but peer-full. We've loosely named some circles you can
join that will help us connect you with folks at the same stage of
development as you, and with resources that are in line with your
needs and interests. But none of these circles is superior, and
there's no harm or shame in sticking with one for a while, or moving
between them to find the best fit. Choosing your financial
contribution level is also not about paying for access to additional
resources - everything is available to every member, no matter their
circle or contribution level. Rather, it's about finding a dues
level that's meaningful to you but not a burden.
</p> </p>
<ul
class="list-disc pl-6 text-lg leading-relaxed text-[--ui-text-muted] space-y-3 mb-12"
>
<li>
The entire knowledge commons, all events, and full community
participation on our private Slack
</li>
<li>One member, one vote in all decisions</li>
<li>Pay what you can ($0-50+/month)</li>
<li>Contribute your skills, time, and knowledge</li>
</ul>
</div>
</div> </div>
</UContainer> </UContainer>
</section> </section>
<!-- Find Your Circle --> <!-- Link to Membership Circles -->
<section class="py-20 bg-[--ui-bg-elevated]"> <section class="py-20 bg-[--ui-bg-elevated]">
<UContainer> <UContainer>
<div class="max-w-4xl mx-auto"> <div class="max-w-3xl">
<h2 class="text-3xl font-bold text-[--ui-text] mb-4"> <h2 class="text-2xl font-bold text-[--ui-text] mb-4">
Find your circle Membership Circles
</h2> </h2>
<p class="text-lg text-[--ui-text-muted] mb-12"> <p class="text-lg text-[--ui-text-muted] mb-6">
Circles help us provide relevant guidance and connect you with Learn about our three membership circles and find where you fit.
others at similar stages. Choose based on where you are now!
</p> </p>
<UButton to="/about/circles" variant="outline" size="lg">
<div class="space-y-12"> Explore Membership Circles
<!-- Community Circle --> </UButton>
<div class="">
<h3 class="text-2xl font-bold text-[--ui-text] mb-2">
Community Circle
</h3>
<div
class="prose prose-lg dark:prose-invert max-w-none text-[--ui-text-muted]"
>
<p>
Maybe you've heard rumours about cooperatives in game dev and
you're curious. Or you're frustrated with traditional studio
hierarchies and wondering if there's another way. This circle
is for anyone exploring whether cooperative principles might
fit their work.
</p>
<p>
This space is for you if you're: an individual game worker
dreaming of different possibilities a researcher digging
into the rise of alternative studio models an industry ally
who wants to support cooperative work <em>anyone</em> who's
co-op-curious!
</p>
<p>
Our resources and community space will help you understand
cooperative basics, connect with others asking the same
questions, and give you a look at real examples from game
studios. You don't need to have a studio or project of your
own - just join and see what strikes your fancy!
</p>
</div>
</div>
<!-- Founder Circle -->
<div class="">
<h3 class="text-2xl font-bold text-[--ui-text] mb-2">
Founder Circle
</h3>
<div
class="prose prose-lg dark:prose-invert max-w-none text-[--ui-text-muted]"
>
<p>
You're way past wondering about "what if" and into "how do we
actually do this?" Perhaps you're forming a new cooperative
studio from scratch, or converting an existing team to a co-op
structure, or working through the messy reality of turning
values into sustainable practice.
</p>
<p>
This is the space for the practical stuff: governance
documents you can read and adapt, financial models for
cooperative studios, connections with other founders
navigating similar challenges.
</p>
<p>
We have two paths through this circle that we will be
launching soon:
</p>
<ul>
<li>
Peer Accelerator Prep Track <em>(coming soon)</em>
Structured preparation if you're planning to apply for the
PA program
</li>
<li>
Indie Track <em>(coming soon)</em> Flexible, self-paced
support for teams building at their own pace
</li>
</ul>
<p>
Join us to figure out how you can balance your values with
keeping the lights on - whether you're a full founding team, a
solo founder exploring structures, or an existing studio in
transition.
</p>
</div>
</div>
<!-- Practitioner Circle -->
<div class="">
<h3 class="text-2xl font-bold text-[--ui-text] mb-2">
Practitioner Circle
</h3>
<div
class="prose prose-lg dark:prose-invert max-w-none text-[--ui-text-muted]"
>
<p>
You've done it. You're actually running a
cooperative/worker-centric studio or you've been through our
Peer Accelerator. Now you're figuring out how to sustain it,
improve it, and maybe help others learn from what
<em>you've</em> learned.
</p>
<p>
This circle is for: Peer Accelerator alumni members of
established co-ops mentors who want to support other
cooperatives researchers studying cooperative models in
practice
</p>
<p>
Here, we create space for practitioners to share what's
actually working (and what isn't), support emerging
cooperatives, collaborate across studios, and contribute to
building a knowledge commons.
</p>
</div>
</div>
</div>
</div>
</UContainer>
</section>
<!-- Important Notes -->
<section class="py-20 bg-[--ui-bg]">
<UContainer>
<div class="max-w-4xl mx-auto">
<h2 class="text-3xl font-bold text-[--ui-text] mb-8">
Important Notes
</h2>
<div class="space-y-6 text-lg text-[--ui-text-muted]">
<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>
<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>
<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>
</div> </div>
</UContainer> </UContainer>
</section> </section>

213
app/pages/about/circles.vue Normal file
View file

@ -0,0 +1,213 @@
<template>
<div>
<!-- Page Header -->
<PageHeader
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"
/>
<!-- How Ghost Guild Works -->
<section class="py-20 bg-[--ui-bg]">
<UContainer>
<div class="max-w-4xl mx-auto">
<h2 class="text-3xl font-bold text-[--ui-text] mb-6">
How membership works
</h2>
<div class="prose prose-lg dark:prose-invert max-w-none">
<p class="text-xl font-semibold text-[--ui-text] 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>
<ul
class="list-disc pl-6 text-lg leading-relaxed text-[--ui-text-muted] space-y-3 mb-12"
>
<li>
The entire knowledge commons, all events, and full community
participation on our private Slack
</li>
<li>One member, one vote in all decisions</li>
<li>Pay what you can ($0-50+/month)</li>
<li>Contribute your skills, time, and knowledge</li>
</ul>
</div>
</div>
</UContainer>
</section>
<!-- Find Your Circle -->
<section class="py-20 bg-[--ui-bg-elevated]">
<UContainer>
<div class="max-w-4xl mx-auto">
<h2 class="text-3xl font-bold text-[--ui-text] mb-4">
Find your circle
</h2>
<p class="text-lg text-[--ui-text-muted] mb-12">
Circles help us provide relevant guidance and connect you with
others at similar stages. Choose based on where you are now!
</p>
<div class="space-y-12">
<!-- Community Circle -->
<div class="">
<h3 class="text-2xl font-bold text-[--ui-text] mb-2">
Community Circle
</h3>
<div
class="prose prose-lg dark:prose-invert max-w-none text-[--ui-text-muted]"
>
<p>
Maybe you've heard rumours about cooperatives in game dev and
you're curious. Or you're frustrated with traditional studio
hierarchies and wondering if there's another way. This circle
is for anyone exploring whether cooperative principles might
fit their work.
</p>
<p>
This space is for you if you're: an individual game worker
dreaming of different possibilities a researcher digging
into the rise of alternative studio models an industry ally
who wants to support cooperative work <em>anyone</em> who's
co-op-curious!
</p>
<p>
Our resources and community space will help you understand
cooperative basics, connect with others asking the same
questions, and give you a look at real examples from game
studios. You don't need to have a studio or project of your
own - just join and see what strikes your fancy!
</p>
</div>
</div>
<!-- Founder Circle -->
<div class="">
<h3 class="text-2xl font-bold text-[--ui-text] mb-2">
Founder Circle
</h3>
<div
class="prose prose-lg dark:prose-invert max-w-none text-[--ui-text-muted]"
>
<p>
You're way past wondering about "what if" and into "how do we
actually do this?" Perhaps you're forming a new cooperative
studio from scratch, or converting an existing team to a co-op
structure, or working through the messy reality of turning
values into sustainable practice.
</p>
<p>
This is the space for the practical stuff: governance
documents you can read and adapt, financial models for
cooperative studios, connections with other founders
navigating similar challenges.
</p>
<p>
We have two paths through this circle that we will be
launching soon:
</p>
<ul>
<li>
Peer Accelerator Prep Track <em>(coming soon)</em>
Structured preparation if you're planning to apply for the
PA program
</li>
<li>
Indie Track <em>(coming soon)</em> Flexible, self-paced
support for teams building at their own pace
</li>
</ul>
<p>
Join us to figure out how you can balance your values with
keeping the lights on - whether you're a full founding team, a
solo founder exploring structures, or an existing studio in
transition.
</p>
</div>
</div>
<!-- Practitioner Circle -->
<div class="">
<h3 class="text-2xl font-bold text-[--ui-text] mb-2">
Practitioner Circle
</h3>
<div
class="prose prose-lg dark:prose-invert max-w-none text-[--ui-text-muted]"
>
<p>
You've done it. You're actually running a
cooperative/worker-centric studio or you've been through our
Peer Accelerator. Now you're figuring out how to sustain it,
improve it, and maybe help others learn from what
<em>you've</em> learned.
</p>
<p>
This circle is for: Peer Accelerator alumni members of
established co-ops mentors who want to support other
cooperatives researchers studying cooperative models in
practice
</p>
<p>
Here, we create space for practitioners to share what's
actually working (and what isn't), support emerging
cooperatives, collaborate across studios, and contribute to
building a knowledge commons.
</p>
</div>
</div>
</div>
</div>
</UContainer>
</section>
<!-- Important Notes -->
<section class="py-20 bg-[--ui-bg]">
<UContainer>
<div class="max-w-4xl mx-auto">
<h2 class="text-3xl font-bold text-[--ui-text] mb-8">
Important Notes
</h2>
<div class="space-y-6 text-lg text-[--ui-text-muted]">
<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>
<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>
<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>
</div>
</UContainer>
</section>
</div>
</template>
<script setup>
// No specific logic needed for the circles page at this time
</script>

View file

@ -79,7 +79,7 @@
<div class="max-w-4xl mx-auto"> <div class="max-w-4xl mx-auto">
<!-- Event Meta Info --> <!-- Event Meta Info -->
<div class="mb-8"> <div class="mb-8">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6"> <div class="grid grid-cols-1 md:grid-cols-4 gap-6">
<div> <div>
<p class="text-sm text-ghost-400">Date</p> <p class="text-sm text-ghost-400">Date</p>
<p class="font-semibold text-ghost-100"> <p class="font-semibold text-ghost-100">
@ -100,6 +100,20 @@
{{ event.location }} {{ event.location }}
</p> </p>
</div> </div>
<div>
<p class="text-sm text-ghost-400">Calendar</p>
<UButton
:href="`/api/events/${route.params.id}/calendar`"
download
variant="outline"
size="sm"
class="mt-1"
icon="i-heroicons-calendar-days"
>
Add to Calendar
</UButton>
</div>
</div> </div>
</div> </div>
@ -270,28 +284,64 @@
</div> </div>
</div> </div>
<!-- Logged In - Can Register --> <!-- Member Status Check (pending payment, suspended, cancelled) -->
<div v-else-if="memberData && !canRSVP" class="text-center">
<div <div
v-else-if="memberData && (!event.membersOnly || isMember)" :class="[
class="text-center" 'p-6 rounded-lg border mb-6',
statusConfig.bgColor,
statusConfig.borderColor,
]"
> >
<p class="text-lg text-ghost-200 mb-6"> <Icon
You are logged in, {{ memberData.name }}. :name="statusConfig.icon"
:class="['w-8 h-8 mx-auto mb-3', statusConfig.textColor]"
/>
<p
:class="[
'font-semibold text-lg mb-2',
statusConfig.textColor,
]"
>
{{ statusConfig.label }}
</p>
<p :class="['mb-4', statusConfig.textColor]">
{{ getRSVPMessage }}
</p> </p>
<UButton <UButton
color="primary" v-if="isPendingPayment"
size="xl" color="orange"
@click="handleRegistration" size="lg"
:loading="isRegistering" class="px-8"
class="px-12 py-4" :loading="isProcessingPayment"
@click="completePayment"
> >
{{ isRegistering ? "Registering..." : "Register Now" }} {{
isProcessingPayment ? "Processing..." : "Complete Payment"
}}
</UButton> </UButton>
<NuxtLink
v-else-if="isCancelled"
to="/member/profile#account"
>
<UButton color="blue" size="lg" class="px-8">
Reactivate Membership
</UButton>
</NuxtLink>
<a
v-else-if="isSuspended"
href="mailto:support@ghostguild.org"
>
<UButton color="gray" size="lg" class="px-8">
Contact Support
</UButton>
</a>
</div>
</div> </div>
<!-- Member Gate Warning --> <!-- Member Gate Warning (members-only locked event) -->
<div <div
v-else-if="event.membersOnly && !isMember" v-else-if="event.membersOnly && memberData && !isMember"
class="text-center" class="text-center"
> >
<div <div
@ -312,6 +362,25 @@
</NuxtLink> </NuxtLink>
</div> </div>
<!-- Logged In - Can Register -->
<div
v-else-if="memberData && (!event.membersOnly || isMember)"
class="text-center"
>
<p class="text-lg text-ghost-200 mb-6">
You are logged in, {{ memberData.name }}.
</p>
<UButton
color="primary"
size="xl"
@click="handleRegistration"
:loading="isRegistering"
class="px-12 py-4"
>
{{ isRegistering ? "Registering..." : "Register Now" }}
</UButton>
</div>
<!-- Not Logged In - Show Registration Form --> <!-- Not Logged In - Show Registration Form -->
<div v-else> <div v-else>
<h3 class="text-xl font-bold text-ghost-100 mb-6"> <h3 class="text-xl font-bold text-ghost-100 mb-6">
@ -403,6 +472,64 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Waitlist Section -->
<div
v-if="event.tickets?.waitlist?.enabled && isEventFull"
class="mt-6 pt-6 border-t border-ghost-700"
>
<!-- Already on Waitlist -->
<div v-if="isOnWaitlist" class="text-center">
<div class="p-4 bg-amber-900/20 rounded-lg border border-amber-800 mb-4">
<p class="font-semibold text-amber-300">
You're on the waitlist!
</p>
<p class="text-sm text-amber-400 mt-1">
Position #{{ waitlistPosition }} - We'll email you if a spot opens up
</p>
</div>
<UButton
color="gray"
variant="outline"
size="sm"
@click="handleLeaveWaitlist"
:loading="isJoiningWaitlist"
>
Leave Waitlist
</UButton>
</div>
<!-- Join Waitlist Form -->
<div v-else>
<div class="p-4 bg-amber-900/20 rounded-lg border border-amber-800 mb-4">
<p class="font-semibold text-amber-300">
This event is full
</p>
<p class="text-sm text-amber-400 mt-1">
Join the waitlist and we'll notify you if a spot opens up
</p>
</div>
<form @submit.prevent="handleJoinWaitlist" class="space-y-4">
<div v-if="!memberData">
<UInput
v-model="waitlistForm.email"
type="email"
placeholder="Your email address"
required
/>
</div>
<UButton
type="submit"
color="warning"
block
:loading="isJoiningWaitlist"
>
{{ isJoiningWaitlist ? "Joining..." : "Join Waitlist" }}
</UButton>
</form>
</div>
</div>
</div> </div>
</div> </div>
@ -446,16 +573,21 @@ if (error.value?.statusCode === 404) {
// Authentication // Authentication
const { isMember, memberData, checkMemberStatus } = useAuth(); const { isMember, memberData, checkMemberStatus } = useAuth();
const {
isPendingPayment,
isSuspended,
isCancelled,
canRSVP,
statusConfig,
getRSVPMessage,
} = useMemberStatus();
const { completePayment, isProcessingPayment, paymentError } =
useMemberPayment();
// Check member status on mount // Check member status on mount
onMounted(async () => { onMounted(async () => {
await checkMemberStatus(); await checkMemberStatus();
// Debug: Log series data
if (event.value?.series) {
console.log("Series data:", event.value.series);
}
// Pre-fill form if member is logged in // Pre-fill form if member is logged in
if (memberData.value) { if (memberData.value) {
registrationForm.value.name = memberData.value.name; registrationForm.value.name = memberData.value.name;
@ -465,6 +597,9 @@ onMounted(async () => {
// Check if user is already registered // Check if user is already registered
await checkRegistrationStatus(); await checkRegistrationStatus();
// Check waitlist status
checkWaitlistStatus();
} }
}); });
@ -507,6 +642,105 @@ const isRegistering = ref(false);
const isCancelling = ref(false); const isCancelling = ref(false);
const registrationStatus = ref("not-registered"); // 'not-registered', 'registered' const registrationStatus = ref("not-registered"); // 'not-registered', 'registered'
// Waitlist state
const isJoiningWaitlist = ref(false);
const isOnWaitlist = ref(false);
const waitlistPosition = ref(0);
const waitlistForm = ref({
email: "",
});
// Computed: Check if event is full
const isEventFull = computed(() => {
if (!event.value?.maxAttendees) return false;
return (event.value.registeredCount || 0) >= event.value.maxAttendees;
});
// Check waitlist status
const checkWaitlistStatus = async () => {
const email = memberData.value?.email || waitlistForm.value.email;
if (!email || !event.value?.tickets?.waitlist?.enabled) return;
const entries = event.value.tickets.waitlist.entries || [];
const entryIndex = entries.findIndex(
(e) => e.email.toLowerCase() === email.toLowerCase()
);
if (entryIndex !== -1) {
isOnWaitlist.value = true;
waitlistPosition.value = entryIndex + 1;
} else {
isOnWaitlist.value = false;
waitlistPosition.value = 0;
}
};
// Join waitlist handler
const handleJoinWaitlist = async () => {
isJoiningWaitlist.value = true;
try {
const email = memberData.value?.email || waitlistForm.value.email;
const name = memberData.value?.name || "Guest";
const response = await $fetch(`/api/events/${route.params.id}/waitlist`, {
method: "POST",
body: { email, name },
});
isOnWaitlist.value = true;
waitlistPosition.value = response.position;
toast.add({
title: "Added to Waitlist",
description: `You're #${response.position} on the waitlist. We'll email you if a spot opens up.`,
color: "orange",
});
} catch (error) {
const errorMessage =
error.data?.statusMessage || "Failed to join waitlist. Please try again.";
toast.add({
title: "Couldn't Join Waitlist",
description: errorMessage,
color: "red",
});
} finally {
isJoiningWaitlist.value = false;
}
};
// Leave waitlist handler
const handleLeaveWaitlist = async () => {
isJoiningWaitlist.value = true;
try {
const email = memberData.value?.email || waitlistForm.value.email;
await $fetch(`/api/events/${route.params.id}/waitlist`, {
method: "DELETE",
body: { email },
});
isOnWaitlist.value = false;
waitlistPosition.value = 0;
toast.add({
title: "Removed from Waitlist",
description: "You've been removed from the waitlist.",
color: "blue",
});
} catch (error) {
toast.add({
title: "Error",
description: "Failed to leave waitlist. Please try again.",
color: "red",
});
} finally {
isJoiningWaitlist.value = false;
}
};
// Format date for display // Format date for display
const formatDate = (dateString) => { const formatDate = (dateString) => {
const date = new Date(dateString); const date = new Date(dateString);
@ -671,7 +905,6 @@ const handleCancelRegistration = async () => {
// Handle ticket purchase success // Handle ticket purchase success
const handleTicketSuccess = (response) => { const handleTicketSuccess = (response) => {
console.log("Ticket purchased successfully:", response);
// Update registered count if needed // Update registered count if needed
if (event.value.registeredCount !== undefined) { if (event.value.registeredCount !== undefined) {
event.value.registeredCount++; event.value.registeredCount++;

View file

@ -13,13 +13,115 @@
<UTabs <UTabs
v-model="activeTab" v-model="activeTab"
:items="[ :items="[
{ label: 'Upcoming Events', value: 'upcoming', slot: 'upcoming' }, { label: 'What\'s On', value: 'upcoming', slot: 'upcoming' },
{ label: 'Calendar', value: 'calendar', slot: 'calendar' }, { label: 'Calendar', value: 'calendar', slot: 'calendar' },
]" ]"
class="max-w-6xl mx-auto" class="max-w-6xl mx-auto"
> >
<template #upcoming> <template #upcoming>
<div class="max-w-4xl mx-auto space-y-6 pt-8"> <!-- Search and Filter Controls -->
<div class="pt-8">
<div class="max-w-4xl mx-auto mb-8">
<div class="flex flex-col gap-4">
<!-- Search Input -->
<div
class="flex flex-row flex-wrap gap-4 items-center justify-center"
>
<UInput
v-model="searchQuery"
placeholder="Search events..."
autocomplete="off"
size="xl"
class="rounded-md shadow-sm text-lg w-full md:w-2/3"
:ui="{ icon: { trailing: { pointer: '' } } }"
>
<template #trailing>
<UButton
v-show="searchQuery"
icon="i-heroicons-x-mark-20-solid"
color="gray"
variant="ghost"
@click="searchQuery = ''"
class="mr-1"
/>
</template>
</UInput>
<!-- Past Events Toggle -->
<div
class="flex flex-col items-center md:items-start gap-2"
>
<label class="text-sm font-medium text-ghost-200">
Show past events?
</label>
<USwitch v-model="includePastEvents" />
</div>
</div>
<!-- Category Filter -->
<div class="flex justify-center">
<UPopover
:ui="{ base: 'overflow-visible' }"
:popper="{
placement: 'bottom-end',
strategy: 'absolute',
}"
>
<UButton
icon="i-heroicons-adjustments-vertical-20-solid"
variant="outline"
color="primary"
size="sm"
class="rounded-md shadow-sm"
>
Filter Events
</UButton>
<template #panel="{ close }">
<div
class="bg-ghost-800 dark:bg-ghost-900 p-6 rounded-md shadow-lg w-80 space-y-4"
>
<div class="space-y-2">
<label class="text-sm font-medium text-ghost-200">
Filter by category
</label>
<USelectMenu
v-model="selectedCategories"
:options="eventCategories"
option-attribute="name"
multiple
value-attribute="id"
placeholder="Select categories..."
:ui="{
base: 'overflow-visible',
menu: { maxHeight: '200px', overflowY: 'auto' },
popper: { strategy: 'absolute' },
}"
/>
</div>
</div>
</template>
</UPopover>
</div>
</div>
<!-- Search Status Message -->
<div v-if="isSearching" class="mt-6 text-center">
<p class="text-ghost-300 text-sm">
{{
filteredEvents.length > 0
? `Found ${filteredEvents.length} event${
filteredEvents.length !== 1 ? "s" : ""
}`
: "No events found matching your search"
}}
</p>
</div>
</div>
</div>
<!-- Events List -->
<div class="max-w-4xl mx-auto space-y-6 pb-8">
<NuxtLink <NuxtLink
v-for="event in upcomingEvents" v-for="event in upcomingEvents"
:key="event.id" :key="event.id"
@ -76,42 +178,42 @@
</template> </template>
<template #calendar> <template #calendar>
<div class="pt-8"> <div class="pt-8 pb-8">
<div class="max-w-6xl mx-auto" id="event-calendar">
<ClientOnly> <ClientOnly>
<div <div
v-if="pending" v-if="pending"
class="min-h-[400px] bg-ghost-700 rounded-xl flex items-center justify-center" class="min-h-[400px] bg-ghost-800 rounded-xl flex items-center justify-center"
> >
<div class="text-center"> <div class="text-center">
<p class="text-ghost-200">Loading events...</p> <div
class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"
></div>
<p class="text-ghost-200">Loading calendar...</p>
</div> </div>
</div> </div>
<div v-else style="min-height: 600px">
<VueCal <VueCal
v-else ref="vuecal"
:events="events" :events="calendarEvents"
:time="false" :time="false"
active-view="month" active-view="month"
class="custom-calendar" class="ghost-calendar"
:disable-views="['years', 'year']" :disable-views="['years', 'year']"
:hide-weekends="false" :hide-view-selector="false"
today-button
events-on-month-view="short" events-on-month-view="short"
:editable-events="{ today-button
title: false,
drag: false,
resize: false,
delete: false,
create: false,
}"
@event-click="onEventClick" @event-click="onEventClick"
/> >
</VueCal>
</div>
<template #fallback> <template #fallback>
<div <div
class="min-h-[400px] bg-ghost-700 rounded-xl flex items-center justify-center" class="min-h-[400px] bg-ghost-800 rounded-xl flex items-center justify-center"
> >
<div class="text-center"> <div class="text-center">
<div <div
class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-4" class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"
></div> ></div>
<p class="text-ghost-200">Loading calendar...</p> <p class="text-ghost-200">Loading calendar...</p>
</div> </div>
@ -119,6 +221,7 @@
</template> </template>
</ClientOnly> </ClientOnly>
</div> </div>
</div>
</template> </template>
</UTabs> </UTabs>
</UContainer> </UContainer>
@ -183,7 +286,7 @@
<div class="series-list-item__events space-y-2 mb-4"> <div class="series-list-item__events space-y-2 mb-4">
<div <div
v-for="(event, index) in series.events.slice(0, 3)" v-for="(event, index) in series.events.slice(0, 3)"
:key="event.id" :key="index"
class="series-list-item__event flex items-center justify-between text-xs" class="series-list-item__event flex items-center justify-between text-xs"
> >
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@ -234,28 +337,16 @@
> >
<div class="prose prose-lg dark:prose-invert max-w-none"> <div class="prose prose-lg dark:prose-invert max-w-none">
<p class="text-lg leading-relaxed text-ghost-200 mb-6"> <p class="text-lg leading-relaxed text-ghost-200 mb-6">
Our events are ,Lorem ipsum, dolor sit amet consectetur Our events bring together game developers, founders, and practitioners
adipisicing elit. Quibusdam exercitationem delectus ab who are building more equitable workplaces. From hands-on workshops
voluptates aspernatur, quia deleniti aut maxime, veniam about governance and finance to casual co-working sessions and game nights,
accusantium non dolores saepe error, ipsam laudantium asperiores there's something for every stage of your journey.
dolorum alias nulla!
</p> </p>
<p class="text-lg leading-relaxed text-ghost-200 mb-6"> <p class="text-lg leading-relaxed text-ghost-200 mb-6">
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do All events are designed to be accessible, with most offered free to members
eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut and sliding-scale pricing for non-members. Can't make it live?
enim ad minim veniam, quis nostrud exercitation ullamco laboris Many sessions are recorded and shared in our resource library.
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> </p>
</div> </div>
</div> </div>
@ -301,19 +392,64 @@
import { VueCal } from "vue-cal"; import { VueCal } from "vue-cal";
import "vue-cal/style.css"; import "vue-cal/style.css";
// Composables
const { formatDateRange, formatDate, isPastDate } = useEventDateUtils();
const {
searchQuery,
includePastEvents,
searchResults,
selectedCategories,
isSearching,
saveSearchState,
loadSearchState,
clearSearch: clearSearchFilters,
} = useCalendarSearch();
// Active tab state // Active tab state
const activeTab = ref("upcoming"); const activeTab = ref("upcoming");
// Hover state for calendar cells
const hoveredCells = ref({});
const handleMouseEnter = (date) => {
hoveredCells.value[date] = true;
};
const handleMouseLeave = (date) => {
hoveredCells.value[date] = false;
};
const isHovered = (date) => {
return hoveredCells.value[date] || false;
};
// Fetch events from API // Fetch events from API
const { data: eventsData, pending, error } = await useFetch("/api/events"); const { data: eventsData, pending, error } = await useFetch("/api/events");
// Fetch series from API // Fetch series from API
const { data: seriesData } = await useFetch("/api/series"); const { data: seriesData } = await useFetch("/api/series");
// Event categories for filtering
const eventCategories = computed(() => {
if (!eventsData.value) return [];
const categories = new Set();
eventsData.value.forEach((event) => {
if (event.eventType) {
categories.add(event.eventType);
}
});
return Array.from(categories).map((type) => ({
id: type,
name: type.charAt(0).toUpperCase() + type.slice(1),
}));
});
// Transform events for calendar display // Transform events for calendar display
const events = computed(() => { const events = computed(() => {
if (!eventsData.value) return []; if (!eventsData.value) {
return [];
}
return eventsData.value.map((event) => ({ const transformed = eventsData.value.map((event) => ({
id: event.id || event._id, id: event.id || event._id,
slug: event.slug, slug: event.slug,
start: new Date(event.startDate), start: new Date(event.startDate),
@ -329,6 +465,71 @@ const events = computed(() => {
featureImage: event.featureImage, featureImage: event.featureImage,
series: event.series, series: event.series,
})); }));
return transformed;
});
// Helper function to format date for VueCal
// When time is disabled, VueCal expects just 'YYYY-MM-DD' format
const formatDateForVueCal = (date) => {
const d = date instanceof Date ? date : new Date(date);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
};
// Transform events for calendar view
const calendarEvents = computed(() => {
const filtered = events.value
.filter((event) => {
// Filter by past events setting
if (!includePastEvents.value && isPastDate(event.start)) {
return false;
}
return true;
})
.map((event) => {
// VueCal expects Date objects when time is disabled
// Only pass title (no content/description) for clean display
return {
start: event.start, // Use Date object directly
end: event.end, // Use Date object directly
title: event.title, // Only title, no description
class: event.class,
};
});
return filtered;
});
// Filter events based on search query and selected categories
const filteredEvents = computed(() => {
return events.value.filter((event) => {
// Filter by past events setting
if (!includePastEvents.value && isPastDate(event.start)) {
return false;
}
// Filter by search query
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase();
const matchesTitle = event.title.toLowerCase().includes(query);
const matchesDescription =
event.content?.toLowerCase().includes(query) || false;
if (!matchesTitle && !matchesDescription) {
return false;
}
}
// Filter by selected categories
if (selectedCategories.value.length > 0) {
if (!selectedCategories.value.includes(event.eventType)) {
return false;
}
}
return true;
});
}); });
// Get active event series // Get active event series
@ -340,13 +541,13 @@ const activeSeries = computed(() => {
); );
}); });
// Get upcoming events (future events) // Get upcoming events (future events, respecting filters)
const upcomingEvents = computed(() => { const upcomingEvents = computed(() => {
const now = new Date(); const now = new Date();
return events.value return filteredEvents.value
.filter((event) => event.start > now) .filter((event) => event.start > now)
.sort((a, b) => a.start - b.start) .sort((a, b) => a.start - b.start)
.slice(0, 6); // Show max 6 upcoming events .slice(0, 10); // Show max 10 upcoming events
}); });
// Format event date for display // Format event date for display
@ -426,30 +627,6 @@ const getSeriesTypeBadgeClass = (type) => {
"bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400" "bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400"
); );
}; };
const formatDateRange = (startDate, endDate) => {
if (!startDate || !endDate) return "No dates";
const start = new Date(startDate);
const end = new Date(endDate);
const startMonth = start.toLocaleDateString("en-US", { month: "short" });
const endMonth = end.toLocaleDateString("en-US", { month: "short" });
const startDay = start.getDate();
const endDay = end.getDate();
const year = end.getFullYear();
if (
start.getMonth() === end.getMonth() &&
start.getFullYear() === end.getFullYear()
) {
return `${startMonth} ${startDay}-${endDay}, ${year}`;
} else if (start.getFullYear() === end.getFullYear()) {
return `${startMonth} ${startDay} - ${endMonth} ${endDay}, ${year}`;
} else {
return `${formatEventDate(startDate)} - ${formatEventDate(endDate)}`;
}
};
</script> </script>
<style scoped> <style scoped>
@ -460,8 +637,199 @@ const formatDateRange = (startDate, endDate) => {
overflow: hidden; overflow: hidden;
} }
/* Custom calendar styling to match the site theme */ /* Calendar styling based on tranzac design principles */
.custom-calendar { .ghost-calendar :deep(.vuecal__event) {
border-radius: 0.5rem;
border: 2px solid #292524;
transform: translateZ(0);
transition: transform 300ms;
}
.ghost-calendar :deep(.vuecal__event:hover) {
transform: scale(1.05);
}
.ghost-calendar :deep(.vuecal__event-title) {
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ghost-calendar :deep(.vuecal__event-time) {
display: none;
}
.ghost-calendar :deep(.vuecal__event.event-community) {
background-color: #2563eb;
color: #f5f5f4;
border-color: #1d4ed8;
}
.ghost-calendar :deep(.vuecal__event.event-workshop) {
background-color: #059669;
color: #f5f5f4;
border-color: #047857;
}
.ghost-calendar :deep(.vuecal__event.event-social) {
background-color: #7c3aed;
color: #f5f5f4;
border-color: #6d28d9;
}
.ghost-calendar :deep(.vuecal__event.event-showcase) {
background-color: #d97706;
color: #f5f5f4;
border-color: #b45309;
}
#event-calendar {
.vuecal__cell-events-count {
position: absolute;
top: 0.5rem;
right: 0.25rem;
left: auto;
background-color: transparent;
color: #f5f5f4;
font-weight: 700;
font-size: 1.25rem;
}
.month-view .vuecal__cell--today,
.vuecal__cell--today {
background-color: rgba(59, 130, 246, 0.15);
color: #f5f5f4;
border: 2px solid #3b82f6 !important;
.day-of-month {
display: flex;
align-items: center;
justify-content: center;
}
.day-of-month::before {
content: "Today";
text-transform: uppercase;
font-size: 0.75rem;
margin-right: 0.5rem;
display: none;
color: #3b82f6;
font-weight: 600;
}
@media (min-width: 768px) {
.day-of-month::before {
display: block;
}
}
}
.vuecal__cell--selected {
background-color: transparent;
}
.past-date {
background-color: rgba(120, 113, 108, 0.1);
color: #78716c;
}
.vuecal__cell--has-events {
}
.vuecal__cell--disabled {
}
.vuecal__cell-events {
}
.vuecal__event {
border-radius: 0.5rem;
border: 2px solid #3a3a3a;
transform: translateZ(0);
transition: transform 300ms;
&:hover {
transform: scale(1.05);
}
&.vuecal__event--selected {
background-color: #2a2a2a;
}
.vuecal__cell--today {
background-color: transparent;
}
.vuecal__event-title {
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: #f5f5f4;
}
.vuecal__event-time {
font-weight: 400;
color: #a8a29e;
}
&.event-community {
background-color: #2563eb;
color: #f5f5f4;
border-color: #1d4ed8;
}
&.event-workshop {
background-color: #059669;
color: #f5f5f4;
border-color: #047857;
}
&.event-social {
background-color: #7c3aed;
color: #f5f5f4;
border-color: #6d28d9;
}
&.event-showcase {
background-color: #d97706;
color: #f5f5f4;
border-color: #b45309;
}
}
.vuecal__cell.vuecal__cell--out-of-scope {
pointer-events: none;
cursor: not-allowed;
border: none;
}
.outOfScope {
height: 100%;
width: 100%;
position: absolute;
background-color: rgba(0, 0, 0, 0.5);
pointer-events: none;
cursor: not-allowed;
}
:not(.vuecal__cell--out-of-scope) .vuecal__cell {
border: 1px solid #3a3a3a;
}
.vuecal__cell:before {
border: none;
}
.vuecal__title-bar {
margin-top: 1rem;
margin-bottom: 1rem;
}
}
/* Ghost calendar theme */
.ghost-calendar {
--vuecal-primary-color: #fff; --vuecal-primary-color: #fff;
--vuecal-text-color: #e7e5e4; --vuecal-text-color: #e7e5e4;
--vuecal-border-color: #57534e; --vuecal-border-color: #57534e;
@ -470,142 +838,124 @@ const formatDateRange = (startDate, endDate) => {
background-color: #292524; background-color: #292524;
} }
.custom-calendar :deep(.vuecal__bg) { .ghost-calendar :deep(.vuecal__bg) {
background-color: #292524; background-color: #292524;
} }
.custom-calendar :deep(.vuecal__header) { .ghost-calendar :deep(.vuecal__header) {
background-color: #1c1917; background-color: #1c1917;
border-bottom: 1px solid #57534e; border-bottom: 1px solid #57534e;
} }
.custom-calendar :deep(.vuecal__title-bar) { .ghost-calendar :deep(.vuecal__title-bar) {
background-color: #1c1917; background-color: #1c1917;
} }
.custom-calendar :deep(.vuecal__title) { .ghost-calendar :deep(.vuecal__title) {
color: #e7e5e4; color: #e7e5e4;
font-weight: 600;
} }
.custom-calendar :deep(.vuecal__weekdays-headings) { .ghost-calendar :deep(.vuecal__weekdays-headings) {
background-color: #1c1917; background-color: #1c1917;
border-bottom: 1px solid #57534e; border-bottom: 1px solid #57534e;
} }
.custom-calendar :deep(.vuecal__heading) { .ghost-calendar :deep(.vuecal__heading) {
color: #a8a29e; color: #a8a29e;
font-weight: 500;
} }
.custom-calendar :deep(.vuecal__cell) { .ghost-calendar :deep(.vuecal__cell) {
background-color: #292524; background-color: #292524;
border-color: #57534e; border-color: #57534e;
color: #e7e5e4; color: #e7e5e4;
} }
.custom-calendar :deep(.vuecal__cell:hover) { .ghost-calendar :deep(.vuecal__cell:hover) {
background-color: #44403c; background-color: #44403c;
border-color: #78716c;
} }
.custom-calendar :deep(.vuecal__cell-content) { .ghost-calendar :deep(.vuecal__cell-content) {
color: #e7e5e4; color: #e7e5e4;
} }
.custom-calendar :deep(.vuecal__cell--today) { .ghost-calendar :deep(.vuecal__cell--today) {
background-color: #44403c; background-color: rgba(59, 130, 246, 0.1);
border: 2px solid #3b82f6;
} }
.custom-calendar :deep(.vuecal__cell--out-of-scope) { .ghost-calendar :deep(.vuecal__cell--out-of-scope) {
background-color: #1c1917; background-color: #1c1917;
color: #78716c; color: #78716c;
} }
.custom-calendar :deep(.vuecal__arrow) { .ghost-calendar :deep(.vuecal__arrow) {
color: #a8a29e; color: #a8a29e;
} }
.custom-calendar :deep(.vuecal__arrow:hover) { .ghost-calendar :deep(.vuecal__arrow:hover) {
background-color: #44403c; background-color: #44403c;
} }
.custom-calendar :deep(.vuecal__today-btn) { .ghost-calendar :deep(.vuecal__today-btn) {
background-color: #44403c; background-color: #44403c;
color: white; color: #fff;
border: 1px solid #78716c; border: 1px solid #78716c;
font-weight: 600;
} }
.custom-calendar :deep(.vuecal__today-btn:hover) { .ghost-calendar :deep(.vuecal__today-btn:hover) {
background-color: #57534e; background-color: #57534e;
border-color: #a8a29e; border-color: #a8a29e;
} }
.custom-calendar :deep(.vuecal__view-btn), .ghost-calendar :deep(.vuecal__view-btn),
.custom-calendar :deep(button[class*="view"]) { .ghost-calendar :deep(button[class*="view"]) {
background-color: #44403c !important; background-color: #44403c !important;
color: #ffffff !important; color: #ffffff !important;
border: 1px solid #78716c !important; border: 1px solid #78716c !important;
font-weight: 600 !important; font-weight: 600 !important;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
} }
.custom-calendar :deep(.vuecal__view-btn:hover), .ghost-calendar :deep(.vuecal__view-btn:hover),
.custom-calendar :deep(button[class*="view"]:hover) { .ghost-calendar :deep(button[class*="view"]:hover) {
background-color: #57534e !important; background-color: #57534e !important;
border-color: #a8a29e !important; border-color: #a8a29e !important;
color: #ffffff !important; color: #ffffff !important;
} }
.custom-calendar :deep(.vuecal__view-btn--active), .ghost-calendar :deep(.vuecal__view-btn--active),
.custom-calendar :deep(button[class*="view"][class*="active"]) { .ghost-calendar :deep(button[class*="view"][class*="active"]) {
background-color: #0c0a09 !important; background-color: #0c0a09 !important;
color: #ffffff !important; color: #ffffff !important;
border-color: #a8a29e !important; border-color: #a8a29e !important;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
} }
.custom-calendar :deep(.vuecal__view-btn--active:hover), .ghost-calendar :deep(.vuecal__view-btn--active:hover),
.custom-calendar :deep(button[class*="view"][class*="active"]:hover) { .ghost-calendar :deep(button[class*="view"][class*="active"]:hover) {
background-color: #1c1917 !important; background-color: #1c1917 !important;
border-color: #d6d3d1 !important; border-color: #d6d3d1 !important;
color: #ffffff !important; color: #ffffff !important;
} }
.custom-calendar :deep(.vuecal__title-bar button) { .ghost-calendar :deep(.vuecal__title-bar button) {
color: #ffffff !important; color: #ffffff !important;
font-weight: 600 !important; font-weight: 600 !important;
} }
.custom-calendar :deep(.vuecal__title-bar .default-view-btn) { .ghost-calendar :deep(.vuecal__title-bar .default-view-btn) {
background-color: #44403c !important; background-color: #44403c !important;
color: #ffffff !important; color: #ffffff !important;
border: 1px solid #78716c !important; border: 1px solid #78716c !important;
} }
.custom-calendar :deep(.vuecal__title-bar .default-view-btn.active) { .ghost-calendar :deep(.vuecal__title-bar .default-view-btn.active) {
background-color: #0c0a09 !important; background-color: #0c0a09 !important;
border-color: #a8a29e !important; border-color: #a8a29e !important;
} }
/* Event type styling */
.vuecal__event.event-community {
background-color: #3b82f6;
border-color: #2563eb;
}
.vuecal__event.event-workshop {
background-color: #10b981;
border-color: #059669;
}
.vuecal__event.event-social {
background-color: #8b5cf6;
border-color: #7c3aed;
}
.vuecal__event.event-showcase {
background-color: #f59e0b;
border-color: #d97706;
}
/* Responsive calendar */ /* Responsive calendar */
.vuecal { .vuecal {
border-radius: 0.75rem; border-radius: 0.75rem;

View file

@ -13,7 +13,7 @@
<!-- Floating subtitle --> <!-- Floating subtitle -->
<div class="mb-16"> <div class="mb-16">
<p class="text-ghost-100 text-lg max-w-md"> <p class="text-ghost-100 text-lg max-w-md">
A community for creatives and game devs<br /> A peer community for creatives and game devs<br />
exploring cooperative models exploring cooperative models
</p> </p>
</div> </div>
@ -87,13 +87,14 @@
<div class="max-w-2xl"> <div class="max-w-2xl">
<p class="text-ghost-300 leading-loose text-lg mb-8"> <p class="text-ghost-300 leading-loose text-lg mb-8">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ghost Guild is Baby Ghosts' membership program, and a community of
game makers building studios that center workers, not just profits.
</p> </p>
<p class="text-ghost-400 leading-relaxed ml-8"> <p class="text-ghost-400 leading-relaxed ml-8">
Sed do eiusmod tempor incididunt ut labore et dolore magna There's space for you no matter where you are in your cooperative
aliqua.<br /> journey and no matter where in the world you are! You'll find peers,
Ut enim ad minim veniam, quis nostrud exercitation. resources, and support here.
</p> </p>
</div> </div>

View file

@ -4,7 +4,7 @@
<PageHeader <PageHeader
v-if="!isAuthenticated" v-if="!isAuthenticated"
title="Join Ghost Guild" title="Join Ghost Guild"
subtitle="Become a member of our community and start building a more worker-centric future for games." subtitle=""
theme="gray" theme="gray"
size="large" size="large"
/> />
@ -16,6 +16,27 @@
size="large" size="large"
/> />
<!-- How Ghost Guild Works -->
<section class="py-20 bg-[--ui-bg-elevated]">
<UContainer>
<div class="max-w-2xl">
<h2 class="text-3xl font-bold text-[--ui-text] mb-6">
How Membership Works
</h2>
<p class="text-lg text-[--ui-text] mb-4">
Every member gets full access to our resource library, workshops,
events, Slack community, and peer support. Your circle connects you
with other folks and resources for your stage.
</p>
<p class="text-lg text-[--ui-text]">
Contribute what you can afford ($050+/month). Higher contributions
create solidarity spots for those who need them. You can adjust
anytime.
</p>
</div>
</UContainer>
</section>
<!-- Membership Sign Up Form --> <!-- Membership Sign Up Form -->
<section v-if="!isAuthenticated" class="py-20 bg-[--ui-bg]"> <section v-if="!isAuthenticated" class="py-20 bg-[--ui-bg]">
<UContainer class="max-w-4xl"> <UContainer class="max-w-4xl">
@ -23,11 +44,6 @@
<h2 class="text-3xl font-bold text-[--ui-text] mb-4"> <h2 class="text-3xl font-bold text-[--ui-text] mb-4">
Membership Sign Up Membership Sign Up
</h2> </h2>
<p class="text-lg text-[--ui-text]">
Choose your circle to connect with others at your stage. Choose your
contribution based on what you can afford. Everyone gets full
access.
</p>
</div> </div>
<!-- Step Indicators --> <!-- Step Indicators -->
@ -385,123 +401,6 @@
</div> </div>
</UContainer> </UContainer>
</section> </section>
<!-- How Ghost Guild Works -->
<section class="py-20 bg-[--ui-bg-elevated]">
<UContainer>
<div class="max-w-4xl mx-auto">
<div class="text-center mb-12">
<h2 class="text-3xl font-bold text-[--ui-text] mb-4">
How Ghost Guild Works
</h2>
<p class="text-lg text-[--ui-text]">
Every member gets everything. Your circle helps you find relevant
content and peers. Your contribution helps sustain our solidarity
economy.
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<!-- Full Access -->
<div class="bg-[--ui-bg] rounded-xl p-6">
<h3 class="text-xl font-semibold mb-4 text-[--ui-text]">
Full Access
</h3>
<ul class="text-[--ui-text] space-y-2">
<li>Complete resource library</li>
<li>All workshops and events</li>
<li>Slack community</li>
<li>Voting rights</li>
<li>Peer support opportunities</li>
</ul>
</div>
<!-- Circle-Specific Guidance -->
<div class="bg-[--ui-bg] rounded-xl p-6">
<h3 class="text-xl font-semibold mb-4 text-[--ui-text]">
Circle-Specific Guidance
</h3>
<ul class="text-[--ui-text] space-y-2">
<li>Resources for your stage</li>
<li>Connection with peers</li>
<li>Workshop recommendations</li>
<li>Support for your challenges</li>
</ul>
</div>
</div>
</div>
</UContainer>
</section>
<!-- How to Join -->
<section class="py-20 bg-[--ui-bg]">
<UContainer>
<div class="max-w-4xl mx-auto">
<div class="space-y-8">
<div class="flex items-start gap-6">
<div class="flex-shrink-0">
<div
class="w-12 h-12 bg-neutral-900 rounded-full flex items-center justify-center text-neutral-50 font-bold text-xl"
>
1
</div>
</div>
<div class="flex-1">
<h3 class="text-xl font-semibold mb-2 text-[--ui-text]">
Pick your circle
</h3>
<p class="text-[--ui-text]">
Where are you in your co-op journey? Select based on where you
are in your cooperative journey - exploring, building, or
practicing. Not sure? Start with Community.
</p>
</div>
</div>
<div class="flex items-start gap-6">
<div class="flex-shrink-0">
<div
class="w-12 h-12 bg-neutral-900 rounded-full flex items-center justify-center text-neutral-50 font-bold text-xl"
>
2
</div>
</div>
<div class="flex-1">
<h3 class="text-xl font-semibold mb-2 text-[--ui-text]">
Choose your contribution
</h3>
<p class="text-[--ui-text]">
What can you afford? ($0-50+/month) Choose based on your
financial capacity. From $0 for those who need support to $50+
for those who can sponsor others. You can adjust anytime.
</p>
</div>
</div>
<div class="flex items-start gap-6">
<div class="flex-shrink-0">
<div
class="w-12 h-12 bg-neutral-900 rounded-full flex items-center justify-center text-neutral-50 font-bold text-xl"
>
3
</div>
</div>
<div class="flex-1">
<h3 class="text-xl font-semibold mb-2 text-[--ui-text]">
Join the community
</h3>
<p class="text-[--ui-text]">
Get access to everything. Fill out your profile, agree to our
community guidelines, and complete payment (if applicable).
You'll get access to our community as soon as we review your
application.
</p>
</div>
</div>
</div>
</div>
</UContainer>
</section>
</div> </div>
</template> </template>
@ -598,7 +497,6 @@ const handleSubmit = async () => {
}); });
if (response.success) { if (response.success) {
console.log("Customer response:", response);
customerId.value = response.customerId; customerId.value = response.customerId;
customerCode.value = response.customerCode; customerCode.value = response.customerCode;
@ -608,13 +506,6 @@ const handleSubmit = async () => {
// Move to next step // Move to next step
if (needsPayment.value) { if (needsPayment.value) {
currentStep.value = 2; currentStep.value = 2;
// Debug log
console.log(
"Customer ID:",
customerId.value,
"Customer Code:",
customerCode.value,
);
// Initialize HelcimPay.js session for card verification // Initialize HelcimPay.js session for card verification
await initializeHelcimPay(customerId.value, customerCode.value, 0); await initializeHelcimPay(customerId.value, customerCode.value, 0);
} else { } else {
@ -623,9 +514,9 @@ const handleSubmit = async () => {
// Check member status to ensure user is properly authenticated // Check member status to ensure user is properly authenticated
await checkMemberStatus(); await checkMemberStatus();
// Automatically redirect to dashboard after a short delay // Automatically redirect to welcome page after a short delay
setTimeout(() => { setTimeout(() => {
navigateTo("/member/dashboard"); navigateTo("/welcome");
}, 3000); // 3 second delay to show success message }, 3000); // 3 second delay to show success message
} }
} }
@ -642,21 +533,16 @@ const handleSubmit = async () => {
const processPayment = async () => { const processPayment = async () => {
if (isSubmitting.value) return; if (isSubmitting.value) return;
console.log("Starting payment process...");
isSubmitting.value = true; isSubmitting.value = true;
errorMessage.value = ""; errorMessage.value = "";
try { try {
console.log("Calling verifyPayment()...");
// Verify payment through HelcimPay.js // Verify payment through HelcimPay.js
const paymentResult = await verifyPayment(); const paymentResult = await verifyPayment();
console.log("Payment result from HelcimPay:", paymentResult);
if (paymentResult.success) { if (paymentResult.success) {
paymentToken.value = paymentResult.cardToken; paymentToken.value = paymentResult.cardToken;
console.log("Payment successful, cardToken:", paymentResult.cardToken);
console.log("Calling verify-payment endpoint...");
// Verify payment on server // Verify payment on server
const verifyResult = await $fetch("/api/helcim/verify-payment", { const verifyResult = await $fetch("/api/helcim/verify-payment", {
method: "POST", method: "POST",
@ -665,9 +551,7 @@ const processPayment = async () => {
customerId: customerId.value, customerId: customerId.value,
}, },
}); });
console.log("Payment verification result:", verifyResult);
console.log("Calling createSubscription...");
// Create subscription (don't let subscription errors prevent form progression) // Create subscription (don't let subscription errors prevent form progression)
const subscriptionResult = await createSubscription( const subscriptionResult = await createSubscription(
paymentResult.cardToken, paymentResult.cardToken,
@ -686,12 +570,6 @@ const processPayment = async () => {
} }
} catch (error) { } catch (error) {
console.error("Payment process error:", error); console.error("Payment process error:", error);
console.error("Error details:", {
message: error.message,
statusCode: error.statusCode,
statusMessage: error.statusMessage,
data: error.data,
});
errorMessage.value = errorMessage.value =
error.message || "Payment verification failed. Please try again."; error.message || "Payment verification failed. Please try again.";
} finally { } finally {
@ -702,12 +580,6 @@ const processPayment = async () => {
// Create subscription // Create subscription
const createSubscription = async (cardToken = null) => { const createSubscription = async (cardToken = null) => {
try { try {
console.log("Creating subscription with:", {
customerId: customerId.value,
contributionTier: form.contributionTier,
cardToken: cardToken ? "present" : "null",
});
const response = await $fetch("/api/helcim/subscription", { const response = await $fetch("/api/helcim/subscription", {
method: "POST", method: "POST",
body: { body: {
@ -718,20 +590,17 @@ const createSubscription = async (cardToken = null) => {
}, },
}); });
console.log("Subscription creation response:", response);
if (response.success) { if (response.success) {
subscriptionData.value = response.subscription; subscriptionData.value = response.subscription;
console.log("Moving to step 3 - success!");
currentStep.value = 3; currentStep.value = 3;
successMessage.value = "Your membership has been activated successfully!"; successMessage.value = "Your membership has been activated successfully!";
// Check member status to ensure user is properly authenticated // Check member status to ensure user is properly authenticated
await checkMemberStatus(); await checkMemberStatus();
// Automatically redirect to dashboard after a short delay // Automatically redirect to welcome page after a short delay
setTimeout(() => { setTimeout(() => {
navigateTo("/member/dashboard"); navigateTo("/welcome");
}, 3000); // 3 second delay to show success message }, 3000); // 3 second delay to show success message
} else { } else {
throw new Error("Subscription creation failed - response not successful"); throw new Error("Subscription creation failed - response not successful");

View file

@ -1,404 +0,0 @@
<template>
<div>
<!-- Page Header -->
<PageHeader
title="Login"
subtitle="Welcome back! Sign in to access your Ghost Guild account and connect with the cooperative community."
theme="blue"
size="large"
/>
<!-- Login Form -->
<section class="py-20 bg-[--ui-bg]">
<UContainer class="max-w-md">
<div class="text-center mb-12">
<h2 class="text-3xl font-bold text-primary-500 mb-4">
Passwordless Login
</h2>
<p class="text-[--ui-text-muted]">
Enter your email to receive a secure login link
</p>
</div>
<div
class="bg-[--ui-bg-elevated] rounded-2xl p-8 shadow-xl border border-primary-200"
>
<UForm :state="loginForm" class="space-y-6" @submit="handleLogin">
<!-- Email Field -->
<UFormField label="Email Address" name="email" required>
<UInput
v-model="loginForm.email"
type="email"
size="xl"
class="w-full"
placeholder="your.email@example.com"
/>
</UFormField>
<!-- Passwordless Info -->
<div class="bg-primary-50 p-4 rounded-lg border border-primary-200">
<div class="flex items-start gap-3">
<div class="space-y-1 flex-shrink-0 mt-1">
<div class="w-2 h-2 bg-blue-500 rounded-full" />
<div class="w-2 h-2 bg-blue-400 rounded-full" />
</div>
<div class="space-y-2 flex-1">
<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>
</div>
<p class="text-primary-700 text-sm mt-3">
We'll send you a secure login link via email. No password
needed!
</p>
</div>
<!-- Login Button -->
<div class="flex justify-center pt-4">
<UButton
type="submit"
:loading="isLoggingIn"
:disabled="!isLoginFormValid"
size="xl"
class="w-full"
>
Send Magic Link
</UButton>
</div>
</UForm>
<!-- Success/Error Messages -->
<div
v-if="loginSuccess"
class="mt-6 p-4 bg-green-50 rounded-lg border border-green-200"
>
<p class="text-green-700 text-center">
Magic link sent! Check your email and click the link to sign
in.
</p>
</div>
<div
v-if="loginError"
class="mt-6 p-4 bg-red-50 rounded-lg border border-red-200"
>
<p class="text-red-700 text-center"> {{ loginError }}</p>
</div>
<!-- Sign Up Link -->
<div class="mt-6 text-center">
<p class="text-[--ui-text-muted]">
Don't have an account?
<NuxtLink
to="/join"
class="text-primary-500 hover:underline font-medium"
>
Join Ghost Guild
</NuxtLink>
</p>
</div>
</div>
</UContainer>
</section>
<!-- Forgot Password -->
<section id="forgot-password" class="py-20 bg-neutral-50">
<UContainer class="max-w-md">
<div class="text-center mb-12">
<h2 class="text-3xl font-bold text-primary-500 mb-4">
Forgot Password
</h2>
<p class="text-[--ui-text-muted]">
Enter your email to receive a password reset link
</p>
</div>
<div
class="bg-[--ui-bg-elevated] rounded-2xl p-8 shadow-xl border border-primary-200"
>
<UForm
:state="forgotPasswordForm"
class="space-y-6"
@submit="handleForgotPassword"
>
<!-- Email Field -->
<UFormField label="Email Address" name="email" required>
<UInput
v-model="forgotPasswordForm.email"
type="email"
size="xl"
class="w-full"
placeholder="your.email@example.com"
/>
</UFormField>
<!-- Reset Instructions -->
<div class="bg-primary-50 p-4 rounded-lg border border-primary-200">
<div class="flex items-start gap-3">
<div class="space-y-1 flex-shrink-0 mt-1">
<div class="w-2 h-2 bg-blue-500 rounded-full" />
<div class="w-2 h-2 bg-blue-400 rounded-full" />
</div>
<div class="space-y-2 flex-1">
<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>
</div>
<p class="text-primary-700 text-sm mt-3">
We'll send you a secure link to reset your password. Check your
email inbox and spam folder.
</p>
</div>
<!-- Send Reset Link Button -->
<div class="flex justify-center pt-4">
<UButton
type="submit"
:loading="isResettingPassword"
:disabled="!forgotPasswordForm.email"
size="xl"
class="w-full"
variant="outline"
>
Send Reset Link
</UButton>
</div>
</UForm>
<!-- Success/Error Messages -->
<div
v-if="resetSuccess"
class="mt-6 p-4 bg-green-50 rounded-lg border border-green-200"
>
<p class="text-green-700 text-center">
Password reset link sent! Check your email.
</p>
</div>
<div
v-if="resetError"
class="mt-6 p-4 bg-red-50 rounded-lg border border-red-200"
>
<p class="text-red-700 text-center"> {{ resetError }}</p>
</div>
</div>
</UContainer>
</section>
<!-- Sign In CTA -->
<section class="py-20 bg-[--ui-bg]">
<UContainer>
<div class="text-center max-w-2xl mx-auto">
<h2 class="text-3xl font-bold text-primary-500 mb-8">Sign In</h2>
<div class="space-y-4 mb-8">
<div class="h-2 bg-blue-500 rounded-full w-64 mx-auto" />
<div class="h-2 bg-blue-300 rounded-full w-48 mx-auto" />
</div>
<p class="text-lg text-[--ui-text-muted] mb-8">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ready to
access your account and connect with the community?
</p>
<UButton
@click="scrollToLoginForm"
size="xl"
color="primary"
class="px-12"
>
Login Now
</UButton>
</div>
</UContainer>
</section>
<!-- Access Your Dashboard -->
<section class="py-20 bg-primary-50">
<UContainer>
<div class="text-center max-w-3xl mx-auto">
<h2 class="text-3xl font-bold text-primary-500 mb-8">
Access Your Dashboard
</h2>
<div class="space-y-3 mb-8">
<div class="h-2 bg-blue-500 rounded-full w-full max-w-lg mx-auto" />
<div class="h-2 bg-blue-400 rounded-full w-full max-w-md mx-auto" />
<div class="h-2 bg-blue-300 rounded-full w-full max-w-sm mx-auto" />
<div class="h-2 bg-blue-200 rounded-full w-full max-w-xs mx-auto" />
</div>
<div
class="bg-[--ui-bg-elevated] rounded-2xl p-8 shadow-lg border border-primary-200 mb-8"
>
<p class="text-lg text-[--ui-text-muted] mb-6">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Once
you're logged in, you'll have access to:
</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 text-left">
<div class="space-y-2">
<div class="flex items-center gap-2">
<div class="w-2 h-2 bg-blue-500 rounded-full" />
<span class="text-[--ui-text]"
>Community forums and discussions</span
>
</div>
<div class="flex items-center gap-2">
<div class="w-2 h-2 bg-blue-400 rounded-full" />
<span class="text-[--ui-text]"
>Member directory and networking</span
>
</div>
<div class="flex items-center gap-2">
<div class="w-2 h-2 bg-blue-300 rounded-full" />
<span class="text-[--ui-text]"
>Educational resources and workshops</span
>
</div>
</div>
<div class="space-y-2">
<div class="flex items-center gap-2">
<div class="w-2 h-2 bg-emerald-500 rounded-full" />
<span class="text-[--ui-text]"
>Cooperative development tools</span
>
</div>
<div class="flex items-center gap-2">
<div class="w-2 h-2 bg-emerald-400 rounded-full" />
<span class="text-[--ui-text]">Mentorship opportunities</span>
</div>
<div class="flex items-center gap-2">
<div class="w-2 h-2 bg-emerald-300 rounded-full" />
<span class="text-[--ui-text]"
>Project collaboration spaces</span
>
</div>
</div>
</div>
</div>
<div class="text-center">
<p class="text-[--ui-text-muted] mb-4">New to Ghost Guild?</p>
<UButton to="/join" variant="outline" size="lg" class="px-8">
Create Your Account
</UButton>
</div>
</div>
</UContainer>
</section>
</div>
</template>
<script setup>
import { reactive, ref, computed } from "vue";
// Login form state
const loginForm = reactive({
email: "",
});
// Forgot password form state
const forgotPasswordForm = reactive({
email: "",
});
// UI state
const isLoggingIn = ref(false);
const isResettingPassword = ref(false);
const loginSuccess = ref(false);
const loginError = ref("");
const resetSuccess = ref(false);
const resetError = ref("");
// Form validation
const isLoginFormValid = computed(() => {
return loginForm.email && loginForm.email.includes("@");
});
// Login handler
const handleLogin = async () => {
if (isLoggingIn.value) return;
isLoggingIn.value = true;
loginError.value = "";
loginSuccess.value = false;
try {
// Call the passwordless login API
const response = await $fetch("/api/auth/login", {
method: "POST",
body: {
email: loginForm.email,
},
});
if (response.success) {
loginSuccess.value = true;
loginError.value = "";
// Clear the form
loginForm.email = "";
}
} catch (err) {
console.error("Login error:", err);
// Handle different error types
if (err.statusCode === 404) {
loginError.value =
"No account found with that email address. Please check your email or create an account.";
} else if (err.statusCode === 500) {
loginError.value = "Failed to send login email. Please try again later.";
} else {
loginError.value =
err.statusMessage || "Something went wrong. Please try again.";
}
} finally {
isLoggingIn.value = false;
}
};
// Forgot password handler
const handleForgotPassword = async () => {
if (isResettingPassword.value) return;
isResettingPassword.value = true;
resetError.value = "";
resetSuccess.value = false;
try {
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1500));
resetSuccess.value = true;
// Reset form after success
setTimeout(() => {
forgotPasswordForm.email = "";
resetSuccess.value = false;
}, 5000);
} catch (err) {
console.error("Password reset error:", err);
resetError.value = "Failed to send reset email. Please try again.";
} finally {
isResettingPassword.value = false;
}
};
// Scroll functions
const scrollToLoginForm = () => {
const formSection = document.querySelector("form");
if (formSection) {
formSection.scrollIntoView({ behavior: "smooth", block: "center" });
}
};
const scrollToForgotPassword = () => {
const forgotSection = document.getElementById("forgot-password");
if (forgotSection) {
forgotSection.scrollIntoView({ behavior: "smooth", block: "start" });
}
};
</script>

View file

@ -11,7 +11,7 @@
<UContainer class=""> <UContainer class="">
<!-- Loading State --> <!-- Loading State -->
<div <div
v-if="!memberData || authPending" v-if="authPending"
class="flex justify-center items-center py-20" class="flex justify-center items-center py-20"
> >
<div class="text-center"> <div class="text-center">
@ -22,8 +22,27 @@
</div> </div>
</div> </div>
<!-- Unauthenticated State -->
<div
v-else-if="!memberData"
class="flex justify-center items-center py-20"
>
<div class="text-center max-w-md">
<div class="w-16 h-16 bg-ghost-800 border border-ghost-600 rounded-full flex items-center justify-center mx-auto mb-4">
<Icon name="heroicons:lock-closed" class="w-8 h-8 text-ghost-400" />
</div>
<h2 class="text-xl font-semibold text-ghost-100 mb-2">Sign in required</h2>
<p class="text-ghost-400 mb-6">Please sign in to access your member dashboard.</p>
<UButton @click="openLoginModal({ title: 'Sign in to your dashboard', description: 'Enter your email to access your member dashboard' })">
Sign In
</UButton>
</div>
</div>
<!-- Dashboard Content --> <!-- Dashboard Content -->
<div v-else class="space-y-8"> <div v-else class="space-y-8">
<!-- Member Status Banner -->
<MemberStatusBanner :dismissible="true" />
<!-- Welcome Card --> <!-- Welcome Card -->
<UCard <UCard
class="sparkle-field" class="sparkle-field"
@ -39,7 +58,16 @@
<h1 class="text-2xl font-bold text-ghost-100 ethereal-text"> <h1 class="text-2xl font-bold text-ghost-100 ethereal-text">
Welcome to Ghost Guild, {{ memberData?.name }}! Welcome to Ghost Guild, {{ memberData?.name }}!
</h1> </h1>
<p class="text-ghost-300 mt-2">Your membership is active!</p> <p
:class="[
'mt-2',
isActive ? 'text-ghost-300' : statusConfig.textColor,
]"
>
{{
isActive ? "Your membership is active!" : statusConfig.label
}}
</p>
</div> </div>
<div class="flex-shrink-0" v-if="memberData?.avatar"> <div class="flex-shrink-0" v-if="memberData?.avatar">
<img <img
@ -92,18 +120,29 @@
<UButton <UButton
to="/members?peerSupport=true" to="/members?peerSupport=true"
variant="outline" variant="outline"
class="border-ghost-600 text-ghost-200 hover:bg-ghost-800 hover:border-whisper-500 justify-start" :disabled="!canPeerSupport"
:class="[
'border-ghost-600 text-ghost-200 justify-start',
canPeerSupport
? 'hover:bg-ghost-800 hover:border-whisper-500'
: 'opacity-50 cursor-not-allowed',
]"
block block
:title="
!canPeerSupport
? 'Complete your membership to book peer sessions'
: ''
"
> >
Book a Peer Session Book a Peer Session
</UButton> </UButton>
<UButton <UButton
disabled to="https://wiki.ghostguild.org"
target="_blank"
variant="outline" variant="outline"
class="border-ghost-600 text-ghost-500 cursor-not-allowed justify-start" class="border-ghost-600 text-ghost-200 hover:bg-ghost-800 hover:border-whisper-500 justify-start"
block block
title="Coming soon"
> >
Browse Resources Browse Resources
</UButton> </UButton>
@ -309,6 +348,8 @@
<script setup> <script setup>
const { memberData, checkMemberStatus } = useAuth(); const { memberData, checkMemberStatus } = useAuth();
const { isActive, statusConfig, isPendingPayment, canPeerSupport } = useMemberStatus();
const { completePayment, isProcessingPayment } = useMemberPayment();
const registeredEvents = ref([]); const registeredEvents = ref([]);
const loadingEvents = ref(false); const loadingEvents = ref(false);
@ -340,6 +381,8 @@ const copyCalendarLink = async () => {
} }
}; };
const { openLoginModal } = useLoginModal();
// Handle authentication check on page load // Handle authentication check on page load
const { pending: authPending } = await useLazyAsyncData( const { pending: authPending } = await useLazyAsyncData(
"dashboard-auth", "dashboard-auth",
@ -347,52 +390,38 @@ const { pending: authPending } = await useLazyAsyncData(
// Only check authentication on client side // Only check authentication on client side
if (process.server) return null; if (process.server) return null;
console.log(
"📊 Dashboard auth check - memberData exists:",
!!memberData.value,
);
// If no member data, try to authenticate // If no member data, try to authenticate
if (!memberData.value) { if (!memberData.value) {
console.log(" - No member data, checking authentication...");
const isAuthenticated = await checkMemberStatus(); const isAuthenticated = await checkMemberStatus();
console.log(" - Auth result:", isAuthenticated);
if (!isAuthenticated) { if (!isAuthenticated) {
console.log(" - Redirecting to login"); // Show login modal instead of redirecting
await navigateTo("/login"); openLoginModal({
title: "Sign in to your dashboard",
description: "Enter your email to access your member dashboard",
dismissible: true,
});
return null; return null;
} }
} }
console.log(" - ✅ Dashboard auth successful");
return memberData.value; return memberData.value;
}, },
); );
// Load registered events // Load registered events
const loadRegisteredEvents = async () => { 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; const memberId = memberData.value?._id || memberData.value?.id;
if (!memberId) { if (!memberId) {
console.log("❌ No member ID available");
return; return;
} }
console.log("📅 Loading events for member:", memberId);
loadingEvents.value = true; loadingEvents.value = true;
try { try {
const response = await $fetch("/api/members/my-events", { const response = await $fetch("/api/members/my-events", {
params: { memberId }, params: { memberId },
}); });
console.log("📅 Events response:", response);
registeredEvents.value = response.events; registeredEvents.value = response.events;
} catch (error) { } catch (error) {
console.error("Failed to load registered events:", error); console.error("Failed to load registered events:", error);

View file

@ -10,7 +10,7 @@
<section class="py-12 px-4"> <section class="py-12 px-4">
<UContainer class="px-4"> <UContainer class="px-4">
<!-- Stats --> <!-- Stats -->
<div v-if="!pending" class="mb-8 flex items-center justify-between"> <div v-if="isAuthenticated && !pending" class="mb-8 flex items-center justify-between">
<div class="text-ghost-300"> <div class="text-ghost-300">
<span class="text-2xl font-bold text-ghost-100">{{ total }}</span> <span class="text-2xl font-bold text-ghost-100">{{ total }}</span>
{{ total === 1 ? "update" : "updates" }} posted {{ total === 1 ? "update" : "updates" }} posted
@ -31,6 +31,23 @@
</div> </div>
</div> </div>
<!-- Unauthenticated State -->
<div
v-else-if="!isAuthenticated"
class="flex justify-center items-center py-20"
>
<div class="text-center max-w-md">
<div class="w-16 h-16 bg-ghost-800 border border-ghost-600 rounded-full flex items-center justify-center mx-auto mb-4">
<Icon name="heroicons:lock-closed" class="w-8 h-8 text-ghost-400" />
</div>
<h2 class="text-xl font-semibold text-ghost-100 mb-2">Sign in required</h2>
<p class="text-ghost-400 mb-6">Please sign in to view your updates.</p>
<UButton @click="openLoginModal({ title: 'Sign in to view your updates', description: 'Enter your email to access your updates' })">
Sign In
</UButton>
</div>
</div>
<!-- Updates List --> <!-- Updates List -->
<div v-else-if="updates.length" class="space-y-6"> <div v-else-if="updates.length" class="space-y-6">
<UpdateCard <UpdateCard
@ -111,6 +128,7 @@
<script setup> <script setup>
const { isAuthenticated, checkMemberStatus } = useAuth(); const { isAuthenticated, checkMemberStatus } = useAuth();
const { openLoginModal } = useLoginModal();
const updates = ref([]); const updates = ref([]);
const pending = ref(false); const pending = ref(false);
@ -127,7 +145,11 @@ onMounted(async () => {
if (!isAuthenticated.value) { if (!isAuthenticated.value) {
const authenticated = await checkMemberStatus(); const authenticated = await checkMemberStatus();
if (!authenticated) { if (!authenticated) {
await navigateTo("/login"); // Show login modal instead of redirecting
openLoginModal({
title: "Sign in to view your updates",
description: "Enter your email to access your updates",
});
return; return;
} }
} }

View file

@ -9,8 +9,9 @@
<section class="py-12"> <section class="py-12">
<UContainer> <UContainer>
<!-- Loading State -->
<div <div
v-if="!memberData || loading" v-if="loading"
class="flex justify-center items-center py-20" class="flex justify-center items-center py-20"
> >
<div class="text-center"> <div class="text-center">
@ -23,6 +24,23 @@
</div> </div>
</div> </div>
<!-- Unauthenticated State -->
<div
v-else-if="!memberData"
class="flex justify-center items-center py-20"
>
<div class="text-center max-w-md">
<div class="w-16 h-16 bg-ghost-800 border border-ghost-600 rounded-full flex items-center justify-center mx-auto mb-4">
<Icon name="heroicons:lock-closed" class="w-8 h-8 text-ghost-400" />
</div>
<h2 class="text-xl font-semibold text-ghost-100 mb-2">Sign in required</h2>
<p class="text-ghost-400 mb-6">Please sign in to access your profile settings.</p>
<UButton @click="openLoginModal({ title: 'Sign in to your profile', description: 'Enter your email to manage your profile settings' })">
Sign In
</UButton>
</div>
</div>
<div v-else> <div v-else>
<UTabs :items="tabItems" v-model="activeTab"> <UTabs :items="tabItems" v-model="activeTab">
<template #profile> <template #profile>
@ -49,7 +67,6 @@
<UInput <UInput
v-model="formData.name" v-model="formData.name"
placeholder="Your name" placeholder="Your name"
disabled
class="w-full" class="w-full"
/> />
</UFormField> </UFormField>
@ -551,6 +568,9 @@
<template #account> <template #account>
<div class="space-y-8 mt-8"> <div class="space-y-8 mt-8">
<!-- Member Status Banner -->
<MemberStatusBanner :dismissible="false" />
<!-- Current Membership --> <!-- Current Membership -->
<div> <div>
<h2 <h2
@ -562,6 +582,42 @@
<div <div
class="backdrop-blur-sm bg-white/80 dark:bg-ghost-800/50 border border-gray-200 dark:border-ghost-700 rounded-lg p-6 space-y-4" class="backdrop-blur-sm bg-white/80 dark:bg-ghost-800/50 border border-gray-200 dark:border-ghost-700 rounded-lg p-6 space-y-4"
> >
<!-- Status Badge -->
<div
class="flex items-center justify-between pb-4 border-b border-gray-200 dark:border-ghost-700"
>
<div>
<p class="text-sm text-gray-600 dark:text-ghost-400">
Membership Status
</p>
<div class="flex items-center gap-2 mt-1">
<Icon
:name="statusConfig.icon"
:class="['w-5 h-5', statusConfig.textColor]"
/>
<p
:class="[
'text-lg font-medium',
statusConfig.textColor,
]"
>
{{ statusConfig.label }}
</p>
</div>
</div>
<span
:class="[
'px-4 py-2 rounded-full text-xs font-medium',
statusConfig.bgColor,
statusConfig.borderColor,
'border',
statusConfig.textColor,
]"
>
{{ statusConfig.label }}
</span>
</div>
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div> <div>
<p class="text-sm text-gray-600 dark:text-ghost-400"> <p class="text-sm text-gray-600 dark:text-ghost-400">
@ -752,6 +808,8 @@
<script setup> <script setup>
const { memberData, checkMemberStatus } = useAuth(); const { memberData, checkMemberStatus } = useAuth();
const { openLoginModal } = useLoginModal();
const { statusConfig } = useMemberStatus();
const route = useRoute(); const route = useRoute();
// Initialize active tab from URL hash or default to 'profile' // Initialize active tab from URL hash or default to 'profile'
@ -1156,13 +1214,10 @@ const updateContributionLevel = async () => {
if (!oldTierRequiresPayment && newTierRequiresPayment) { if (!oldTierRequiresPayment && newTierRequiresPayment) {
// Always show payment popup when upgrading from free to paid // Always show payment popup when upgrading from free to paid
// Even if they have a customer ID, they might not have an active subscription // Even if they have a customer ID, they might not have an active subscription
console.log("Upgrading from free to paid - showing payment popup");
await handlePaymentSetup(); await handlePaymentSetup();
console.log("Payment setup completed successfully, returning early");
return; return;
} }
console.log("Calling update-contribution API");
// Call API to update contribution // Call API to update contribution
await $fetch("/api/members/update-contribution", { await $fetch("/api/members/update-contribution", {
method: "POST", method: "POST",
@ -1193,7 +1248,6 @@ const updateContributionLevel = async () => {
if (requiresPayment) { if (requiresPayment) {
// Show payment modal // Show payment modal
console.log("Showing payment modal - payment setup required");
try { try {
await handlePaymentSetup(); await handlePaymentSetup();
// If successful, return early without showing error // If successful, return early without showing error
@ -1228,13 +1282,6 @@ const handlePaymentSetup = async () => {
customerId.value = customerResponse.customerId; customerId.value = customerResponse.customerId;
customerCode.value = customerResponse.customerCode; customerCode.value = customerResponse.customerCode;
console.log(
customerResponse.existing
? "Using existing Helcim customer:"
: "Created new Helcim customer:",
customerId.value,
);
} else { } else {
// Get customer code from existing customer via server-side endpoint // Get customer code from existing customer via server-side endpoint
const customerResponse = await $fetch("/api/helcim/customer-code"); const customerResponse = await $fetch("/api/helcim/customer-code");
@ -1247,7 +1294,6 @@ const handlePaymentSetup = async () => {
// Show payment modal // Show payment modal
const paymentResult = await verifyPayment(); const paymentResult = await verifyPayment();
console.log("Payment result:", paymentResult);
if (!paymentResult.success) { if (!paymentResult.success) {
throw new Error("Payment verification failed"); throw new Error("Payment verification failed");
@ -1328,7 +1374,11 @@ onMounted(async () => {
loading.value = false; loading.value = false;
if (!isAuthenticated) { if (!isAuthenticated) {
await navigateTo("/login"); // Show login modal instead of redirecting
openLoginModal({
title: "Sign in to your profile",
description: "Enter your email to manage your profile settings",
});
return; return;
} }
} }

View file

@ -416,7 +416,7 @@
🔒 Some member information is visible to members only 🔒 Some member information is visible to members only
</p> </p>
<div class="flex gap-3 justify-center"> <div class="flex gap-3 justify-center">
<UButton to="/login" variant="outline"> Log In </UButton> <UButton @click="openLoginModal({ title: 'Sign in to see more', description: 'Log in to view full member profiles' })" variant="outline"> Log In </UButton>
<UButton to="/join"> Join Ghost Guild </UButton> <UButton to="/join"> Join Ghost Guild </UButton>
</div> </div>
</div> </div>
@ -427,6 +427,7 @@
<script setup> <script setup>
const { isAuthenticated } = useAuth(); const { isAuthenticated } = useAuth();
const { openLoginModal } = useLoginModal();
const { render: renderMarkdown } = useMarkdown(); const { render: renderMarkdown } = useMarkdown();
// State // State

281
app/pages/welcome.vue Normal file
View file

@ -0,0 +1,281 @@
<template>
<div>
<PageHeader
title="Welcome to Ghost Guild"
subtitle="You're officially part of the community!"
theme="purple"
size="large"
/>
<section class="py-16 bg-ghost-900">
<UContainer>
<div class="max-w-4xl mx-auto">
<!-- Welcome Message -->
<div class="text-center mb-16">
<div class="w-24 h-24 mx-auto mb-6">
<img
v-if="memberData?.avatar"
:src="`/ghosties/Ghost-${memberData.avatar.charAt(0).toUpperCase() + memberData.avatar.slice(1)}.png`"
:alt="memberData.name"
class="w-full h-full object-contain"
/>
<img
v-else
src="/ghosties/Ghost-Sweet.png"
alt="Ghost Guild"
class="w-full h-full object-contain"
/>
</div>
<h2 class="text-2xl font-bold text-ghost-100 mb-4">
Hey {{ memberData?.name || "there" }}!
</h2>
<p class="text-lg text-ghost-300 max-w-2xl mx-auto">
You've joined a an awesome community!!👻 Welcome to Ghost guild
</p>
</div>
<!-- Getting Started Steps -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-8 mb-16">
<div class="p-6 bg-ghost-800/50 rounded-xl border border-ghost-700">
<div
class="w-12 h-12 bg-purple-500/20 rounded-lg flex items-center justify-center mb-4"
>
<Icon
name="heroicons:user-circle"
class="w-6 h-6 text-purple-400"
/>
</div>
<h3 class="font-semibold text-ghost-100 mb-2">
1. Complete Your Profile
</h3>
<p class="text-sm text-ghost-400 mb-4">
Tell the community about yourself, your skills, and what you're
looking for.
</p>
<UButton to="/member/profile" variant="outline" size="sm">
Edit Profile
</UButton>
</div>
<div class="p-6 bg-ghost-800/50 rounded-xl border border-ghost-700">
<div
class="w-12 h-12 bg-blue-500/20 rounded-lg flex items-center justify-center mb-4"
>
<Icon
name="heroicons:calendar-days"
class="w-6 h-6 text-blue-400"
/>
</div>
<h3 class="font-semibold text-ghost-100 mb-2">
2. Join an Event
</h3>
<p class="text-sm text-ghost-400 mb-4">
From workshops to game nights, events are the heart of our
community.
</p>
<UButton to="/events" variant="outline" size="sm">
Browse Events
</UButton>
</div>
<div class="p-6 bg-ghost-800/50 rounded-xl border border-ghost-700">
<div
class="w-12 h-12 bg-green-500/20 rounded-lg flex items-center justify-center mb-4"
>
<Icon name="heroicons:users" class="w-6 h-6 text-green-400" />
</div>
<h3 class="font-semibold text-ghost-100 mb-2">
3. Meet the Community
</h3>
<p class="text-sm text-ghost-400 mb-4">
Connect with other members and find peers for support and
collaboration.
</p>
<UButton to="/members" variant="outline" size="sm">
View Members
</UButton>
</div>
</div>
<!-- About Circles -->
<div
class="p-8 bg-ghost-800/30 rounded-2xl border border-ghost-700 mb-16"
>
<h3 class="text-xl font-bold text-ghost-100 mb-4">
Understanding Circles
</h3>
<p class="text-ghost-300 mb-6">
Ghost Guild is organized into three circles based on where you are
in your journey. Your circle helps us tailor events and resources
to your needs.
</p>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div
class="p-4 rounded-lg"
:class="
memberData?.circle === 'community'
? 'bg-purple-500/20 border border-purple-500/50'
: 'bg-ghost-800/50'
"
>
<h4 class="font-semibold text-ghost-100 mb-2">
Community Circle
<span
v-if="memberData?.circle === 'community'"
class="text-purple-400 text-sm ml-2"
> You're here</span
>
</h4>
<p class="text-sm text-ghost-400">
For those exploring solidarity economics and alternative
studio models.
</p>
</div>
<div
class="p-4 rounded-lg"
:class="
memberData?.circle === 'founder'
? 'bg-purple-500/20 border border-purple-500/50'
: 'bg-ghost-800/50'
"
>
<h4 class="font-semibold text-ghost-100 mb-2">
Founder Circle
<span
v-if="memberData?.circle === 'founder'"
class="text-purple-400 text-sm ml-2"
> You're here</span
>
</h4>
<p class="text-sm text-ghost-400">
For those actively building or running a cooperative or
solidarity-based studio.
</p>
</div>
<div
class="p-4 rounded-lg"
:class="
memberData?.circle === 'practitioner'
? 'bg-purple-500/20 border border-purple-500/50'
: 'bg-ghost-800/50'
"
>
<h4 class="font-semibold text-ghost-100 mb-2">
Practitioner Circle
<span
v-if="memberData?.circle === 'practitioner'"
class="text-purple-400 text-sm ml-2"
> You're here</span
>
</h4>
<p class="text-sm text-ghost-400">
For consultants, advisors, and professionals supporting
cooperative game studios.
</p>
</div>
</div>
</div>
<!-- Resources -->
<div
class="p-8 bg-ghost-800/30 rounded-2xl border border-ghost-700 mb-16"
>
<h3 class="text-xl font-bold text-ghost-100 mb-4">
Resources & Support
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="flex items-start gap-4">
<div
class="w-10 h-10 bg-amber-500/20 rounded-lg flex items-center justify-center flex-shrink-0"
>
<Icon
name="heroicons:book-open"
class="w-5 h-5 text-amber-400"
/>
</div>
<div>
<h4 class="font-semibold text-ghost-100 mb-1">
Resource Library
</h4>
<p class="text-sm text-ghost-400 mb-2">
Templates, guides, and tools for building solidarity-based
studios.
</p>
<UButton
to="https://wiki.ghostguild.org"
target="_blank"
variant="link"
size="sm"
class="p-0"
>
Browse Resources
</UButton>
</div>
</div>
<div class="flex items-start gap-4">
<div
class="w-10 h-10 bg-pink-500/20 rounded-lg flex items-center justify-center flex-shrink-0"
>
<Icon
name="heroicons:chat-bubble-left-right"
class="w-5 h-5 text-pink-400"
/>
</div>
<div>
<h4 class="font-semibold text-ghost-100 mb-1">
Peer Support
</h4>
<p class="text-sm text-ghost-400 mb-2">
Connect 1:1 with community members for advice and support.
</p>
<UButton
to="/members?peerSupport=true"
variant="link"
size="sm"
class="p-0"
>
Find Peers
</UButton>
</div>
</div>
</div>
</div>
<!-- CTA -->
<div class="text-center">
<UButton to="/member/dashboard" size="xl" class="px-12">
Go to Your Dashboard
</UButton>
</div>
</div>
</UContainer>
</section>
</div>
</template>
<script setup>
const { memberData, checkMemberStatus } = useAuth();
// Ensure user is authenticated
definePageMeta({
middleware: ["auth"],
});
onMounted(async () => {
await checkMemberStatus();
});
useHead({
title: "Welcome - Ghost Guild",
meta: [
{
name: "description",
content: "Welcome to Ghost Guild! Get started with your membership.",
},
],
});
</script>

View file

@ -0,0 +1,228 @@
import mongoose from 'mongoose';
import Event from '../server/models/event.js';
import dotenv from 'dotenv';
dotenv.config();
const MONGODB_URI = process.env.NUXT_MONGODB_URI;
const SERIES_ID = 'cooperative-values-into-practice'; // From the series collection
const moduleEvents = [
{
title: 'Module 0: Orientation',
slug: 'cooperative-values-module-0-orientation',
tagline: 'Welcome to Cooperative Values into Practice',
description: 'An introduction to the series and what to expect',
content: 'Welcome to the Cooperative Values into Practice series. This orientation module will introduce you to the program structure and learning objectives.',
startDate: new Date('2025-11-01T18:00:00.000Z'),
endDate: new Date('2025-11-01T20:00:00.000Z'),
eventType: 'workshop',
location: '#workshop-series',
isOnline: true,
isVisible: true,
isCancelled: false,
membersOnly: false,
registrationRequired: true,
maxAttendees: 50,
targetCircles: [],
agenda: [],
speakers: [],
series: {
id: SERIES_ID,
title: 'Cooperative Values into Practice',
description: 'Creating a democratic and inclusive game studio',
type: 'course',
position: 0,
totalEvents: 6,
isSeriesEvent: true
},
createdBy: 'admin'
},
{
title: 'Module 1: Principles',
slug: 'cooperative-values-module-1-principles',
tagline: 'Core principles of cooperative organizations',
description: 'Learn the foundational principles that guide cooperative businesses',
content: 'Explore the core principles of cooperative organizations and how they apply to game development studios.',
startDate: new Date('2025-11-01T18:00:00.000Z'),
endDate: new Date('2025-11-01T20:00:00.000Z'),
eventType: 'workshop',
location: '#workshop-series',
isOnline: true,
isVisible: true,
isCancelled: false,
membersOnly: false,
registrationRequired: true,
maxAttendees: 50,
targetCircles: [],
agenda: [],
speakers: [],
series: {
id: SERIES_ID,
title: 'Cooperative Values into Practice',
description: 'Creating a democratic and inclusive game studio',
type: 'course',
position: 1,
totalEvents: 6,
isSeriesEvent: true
},
createdBy: 'admin'
},
{
title: 'Module 2: Purpose',
slug: 'cooperative-values-module-2-purpose',
tagline: 'Defining your cooperative purpose',
description: 'Establish the mission and values of your cooperative studio',
content: 'Learn how to define and articulate your cooperative studio\'s purpose, mission, and core values.',
startDate: new Date('2025-11-22T18:00:00.000Z'),
endDate: new Date('2025-11-22T20:00:00.000Z'),
eventType: 'workshop',
location: '#workshop-series',
isOnline: true,
isVisible: true,
isCancelled: false,
membersOnly: false,
registrationRequired: true,
maxAttendees: 50,
targetCircles: [],
agenda: [],
speakers: [],
series: {
id: SERIES_ID,
title: 'Cooperative Values into Practice',
description: 'Creating a democratic and inclusive game studio',
type: 'course',
position: 2,
totalEvents: 6,
isSeriesEvent: true
},
createdBy: 'admin'
},
{
title: 'Module 3: Practices Part 1 - Meetings & Decision-Making',
slug: 'cooperative-values-module-3-practices-part-1',
tagline: 'Democratic practices for effective collaboration',
description: 'Learn meeting facilitation and consensus decision-making processes',
content: 'Develop practical skills in democratic meeting facilitation and collaborative decision-making.',
startDate: new Date('2025-12-20T18:00:00.000Z'),
endDate: new Date('2025-12-20T20:00:00.000Z'),
eventType: 'workshop',
location: '#workshop-series',
isOnline: true,
isVisible: true,
isCancelled: false,
membersOnly: false,
registrationRequired: true,
maxAttendees: 50,
targetCircles: [],
agenda: [],
speakers: [],
series: {
id: SERIES_ID,
title: 'Cooperative Values into Practice',
description: 'Creating a democratic and inclusive game studio',
type: 'course',
position: 3,
totalEvents: 6,
isSeriesEvent: true
},
createdBy: 'admin'
},
{
title: 'Module 4: Practices Part 2 - Finances & Governance',
slug: 'cooperative-values-module-4-practices-part-2',
tagline: 'Financial management and governance structures',
description: 'Explore cooperative financial models and governance frameworks',
content: 'Learn about cooperative financial management, governance structures, and accountability practices.',
startDate: new Date('2026-01-17T18:00:00.000Z'),
endDate: new Date('2026-01-17T20:00:00.000Z'),
eventType: 'workshop',
location: '#workshop-series',
isOnline: true,
isVisible: true,
isCancelled: false,
membersOnly: false,
registrationRequired: true,
maxAttendees: 50,
targetCircles: [],
agenda: [],
speakers: [],
series: {
id: SERIES_ID,
title: 'Cooperative Values into Practice',
description: 'Creating a democratic and inclusive game studio',
type: 'course',
position: 4,
totalEvents: 6,
isSeriesEvent: true
},
createdBy: 'admin'
},
{
title: 'Module 5: Pathways Forward',
slug: 'cooperative-values-module-5-pathways-forward',
tagline: 'Creating your action plan',
description: 'Develop your roadmap for implementing cooperative values',
content: 'Create a concrete action plan for implementing cooperative values and practices in your studio.',
startDate: new Date('2026-02-14T18:00:00.000Z'),
endDate: new Date('2026-02-14T20:00:00.000Z'),
eventType: 'workshop',
location: '#workshop-series',
isOnline: true,
isVisible: true,
isCancelled: false,
membersOnly: false,
registrationRequired: true,
maxAttendees: 50,
targetCircles: [],
agenda: [],
speakers: [],
series: {
id: SERIES_ID,
title: 'Cooperative Values into Practice',
description: 'Creating a democratic and inclusive game studio',
type: 'course',
position: 5,
totalEvents: 6,
isSeriesEvent: true
},
createdBy: 'admin'
}
];
async function addModuleEvents() {
try {
await mongoose.connect(MONGODB_URI);
console.log('Connected to Atlas');
// Check if any Module events already exist
const existing = await Event.find({ title: /^Module/ });
if (existing.length > 0) {
console.log(`Found ${existing.length} existing Module events. Deleting them first...`);
await Event.deleteMany({ title: /^Module/ });
}
// Insert the module events
console.log(`Inserting ${moduleEvents.length} Module events...`);
const inserted = await Event.insertMany(moduleEvents);
console.log(`✓ Successfully inserted ${inserted.length} Module events`);
console.log('\nModule events created:');
inserted.forEach(e => {
console.log(` - ${e.title} (${e.startDate.toISOString().split('T')[0]})`);
});
// Verify
const total = await Event.countDocuments();
const visible = await Event.countDocuments({ isVisible: true });
console.log(`\nTotal events in Atlas: ${total}`);
console.log(`Visible events in Atlas: ${visible}`);
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
}
addModuleEvents();

View file

@ -0,0 +1,203 @@
import mongoose from 'mongoose';
import Event from '../server/models/event.js';
import dotenv from 'dotenv';
dotenv.config();
const MONGODB_URI = process.env.NUXT_MONGODB_URI;
const SERIES_ID = '68eb9c00689e559d9e46322e'; // Cooperative Values into Practice
const moduleEvents = [
{
title: 'Module 0: Orientation',
slug: 'cooperative-values-module-0-orientation',
tagline: 'Welcome to Cooperative Values into Practice',
description: 'An introduction to the series and what to expect',
content: 'Welcome to the Cooperative Values into Practice series. This orientation module will introduce you to the program structure and learning objectives.',
startDate: new Date('2025-11-01T18:00:00.000Z'),
endDate: new Date('2025-11-01T20:00:00.000Z'),
eventType: 'workshop',
location: '#workshop-series',
isOnline: true,
isVisible: true,
isCancelled: false,
membersOnly: false,
registrationRequired: true,
maxAttendees: 50,
series: {
id: SERIES_ID,
title: 'Cooperative Values into Practice',
description: 'Creating a democratic and inclusive game studio',
type: 'course',
position: 0,
totalEvents: 6,
isSeriesEvent: true
},
createdBy: 'admin'
},
{
title: 'Module 1: Principles',
slug: 'cooperative-values-module-1-principles',
tagline: 'Core principles of cooperative organizations',
description: 'Learn the foundational principles that guide cooperative businesses',
content: 'Explore the core principles of cooperative organizations and how they apply to game development studios.',
startDate: new Date('2025-11-01T18:00:00.000Z'),
endDate: new Date('2025-11-01T20:00:00.000Z'),
eventType: 'workshop',
location: '#workshop-series',
isOnline: true,
isVisible: true,
isCancelled: false,
membersOnly: false,
registrationRequired: true,
maxAttendees: 50,
series: {
id: SERIES_ID,
title: 'Cooperative Values into Practice',
description: 'Creating a democratic and inclusive game studio',
type: 'course',
position: 1,
totalEvents: 6,
isSeriesEvent: true
},
createdBy: 'admin'
},
{
title: 'Module 2: Purpose',
slug: 'cooperative-values-module-2-purpose',
tagline: 'Defining your cooperative purpose',
description: 'Establish the mission and values of your cooperative studio',
content: 'Learn how to define and articulate your cooperative studio\'s purpose, mission, and core values.',
startDate: new Date('2025-11-22T18:00:00.000Z'),
endDate: new Date('2025-11-22T20:00:00.000Z'),
eventType: 'workshop',
location: '#workshop-series',
isOnline: true,
isVisible: true,
isCancelled: false,
membersOnly: false,
registrationRequired: true,
maxAttendees: 50,
series: {
id: SERIES_ID,
title: 'Cooperative Values into Practice',
description: 'Creating a democratic and inclusive game studio',
type: 'course',
position: 2,
totalEvents: 6,
isSeriesEvent: true
},
createdBy: 'admin'
},
{
title: 'Module 3: Practices Part 1 - Meetings & Decision-Making',
slug: 'cooperative-values-module-3-practices-part-1',
tagline: 'Democratic practices for effective collaboration',
description: 'Learn meeting facilitation and consensus decision-making processes',
content: 'Develop practical skills in democratic meeting facilitation and collaborative decision-making.',
startDate: new Date('2025-12-20T18:00:00.000Z'),
endDate: new Date('2025-12-20T20:00:00.000Z'),
eventType: 'workshop',
location: '#workshop-series',
isOnline: true,
isVisible: true,
isCancelled: false,
membersOnly: false,
registrationRequired: true,
maxAttendees: 50,
series: {
id: SERIES_ID,
title: 'Cooperative Values into Practice',
description: 'Creating a democratic and inclusive game studio',
type: 'course',
position: 3,
totalEvents: 6,
isSeriesEvent: true
},
createdBy: 'admin'
},
{
title: 'Module 4: Practices Part 2 - Finances & Governance',
slug: 'cooperative-values-module-4-practices-part-2',
tagline: 'Financial management and governance structures',
description: 'Explore cooperative financial models and governance frameworks',
content: 'Learn about cooperative financial management, governance structures, and accountability practices.',
startDate: new Date('2026-01-17T18:00:00.000Z'),
endDate: new Date('2026-01-17T20:00:00.000Z'),
eventType: 'workshop',
location: '#workshop-series',
isOnline: true,
isVisible: true,
isCancelled: false,
membersOnly: false,
registrationRequired: true,
maxAttendees: 50,
series: {
id: SERIES_ID,
title: 'Cooperative Values into Practice',
description: 'Creating a democratic and inclusive game studio',
type: 'course',
position: 4,
totalEvents: 6,
isSeriesEvent: true
},
createdBy: 'admin'
},
{
title: 'Module 5: Pathways Forward',
slug: 'cooperative-values-module-5-pathways-forward',
tagline: 'Creating your action plan',
description: 'Develop your roadmap for implementing cooperative values',
content: 'Create a concrete action plan for implementing cooperative values and practices in your studio.',
startDate: new Date('2026-02-14T18:00:00.000Z'),
endDate: new Date('2026-02-14T20:00:00.000Z'),
eventType: 'workshop',
location: '#workshop-series',
isOnline: true,
isVisible: true,
isCancelled: false,
membersOnly: false,
registrationRequired: true,
maxAttendees: 50,
series: {
id: SERIES_ID,
title: 'Cooperative Values into Practice',
description: 'Creating a democratic and inclusive game studio',
type: 'course',
position: 5,
totalEvents: 6,
isSeriesEvent: true
},
createdBy: 'admin'
}
];
async function addModuleEvents() {
try {
await mongoose.connect(MONGODB_URI);
console.log('Connected to Atlas');
// Check if any Module events already exist
const existing = await Event.find({ title: /^Module/ });
if (existing.length > 0) {
console.log(`Found ${existing.length} existing Module events. Deleting them first...`);
await Event.deleteMany({ title: /^Module/ });
}
// Insert the module events
console.log(`Inserting ${moduleEvents.length} Module events...`);
const inserted = await Event.insertMany(moduleEvents);
console.log(`✓ Successfully inserted ${inserted.length} Module events`);
// Verify
const total = await Event.countDocuments();
console.log(`\nTotal events in Atlas: ${total}`);
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
}
addModuleEvents();

View file

@ -1,44 +1,47 @@
import mongoose from 'mongoose' import mongoose from "mongoose";
import { connectDB } from '../server/utils/mongoose.js' import { connectDB } from "../server/utils/mongoose.js";
import dotenv from 'dotenv' import dotenv from "dotenv";
// Load environment variables // Load environment variables
dotenv.config() dotenv.config();
// Import seed functions // Import seed functions
import { execSync } from 'child_process' import { execSync } from "child_process";
async function seedAll() { async function seedAll() {
try { try {
console.log('🌱 Starting database seeding...\n') console.log("🌱 Starting database seeding...\n");
// Seed members // Seed members
console.log('👥 Seeding members...') console.log("👥 Seeding members...");
execSync('node scripts/seed-members.js', { stdio: 'inherit' }) execSync("node scripts/seed-members.js", { stdio: "inherit" });
console.log('\n🎉 Seeding events...') console.log("\n🎉 Seeding events...");
execSync('node scripts/seed-events.js', { stdio: 'inherit' }) execSync("node scripts/seed-events.js", { stdio: "inherit" });
console.log('\n✅ All data seeded successfully!') console.log("\n📅 Seeding series events...");
console.log('\n📊 Database Summary:') execSync("node scripts/seed-series-events.js", { stdio: "inherit" });
console.log("\n✅ All data seeded successfully!");
console.log("\n📊 Database Summary:");
// Connect and show final counts // Connect and show final counts
await connectDB() await connectDB();
const Member = (await import('../server/models/member.js')).default const Member = (await import("../server/models/member.js")).default;
const Event = (await import('../server/models/event.js')).default const Event = (await import("../server/models/event.js")).default;
const memberCount = await Member.countDocuments() const memberCount = await Member.countDocuments();
const eventCount = await Event.countDocuments() const eventCount = await Event.countDocuments();
console.log(` Members: ${memberCount}`) console.log(` Members: ${memberCount}`);
console.log(` Events: ${eventCount}`) console.log(` Events: ${eventCount}`);
process.exit(0) process.exit(0);
} catch (error) { } catch (error) {
console.error('❌ Error seeding database:', error) console.error("❌ Error seeding database:", error);
process.exit(1) process.exit(1);
} }
} }
seedAll() seedAll();

View file

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

View file

@ -0,0 +1,103 @@
import Event from "../../../models/event";
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, "id");
if (!id) {
throw createError({
statusCode: 400,
statusMessage: "Event ID is required",
});
}
try {
// Find event by ID or slug
const eventData = await Event.findOne({
$or: [{ _id: id }, { slug: id }],
isVisible: true,
isCancelled: { $ne: true },
}).select("title slug description startDate endDate location");
if (!eventData) {
throw createError({
statusCode: 404,
statusMessage: "Event not found",
});
}
// Generate iCal format for single event
const ical = generateSingleEventCalendar(eventData);
// Set headers for download
const filename = `${eventData.slug || eventData._id}.ics`;
setHeader(event, "Content-Type", "text/calendar; charset=utf-8");
setHeader(
event,
"Content-Disposition",
`attachment; filename="${filename}"`
);
setHeader(event, "Cache-Control", "no-cache");
return ical;
} catch (error) {
console.error("Error generating event calendar:", error);
if (error.statusCode) {
throw error;
}
throw createError({
statusCode: 500,
statusMessage: "Failed to generate calendar file",
});
}
});
function generateSingleEventCalendar(evt) {
const now = new Date();
const timestamp = now
.toISOString()
.replace(/[-:]/g, "")
.replace(/\.\d{3}/, "");
const eventStart = new Date(evt.startDate);
const eventEnd = new Date(evt.endDate);
const dtstart = eventStart
.toISOString()
.replace(/[-:]/g, "")
.replace(/\.\d{3}/, "");
const dtend = eventEnd
.toISOString()
.replace(/[-:]/g, "")
.replace(/\.\d{3}/, "");
// Clean description for iCal format
const description = (evt.description || "")
.replace(/\n/g, "\\n")
.replace(/,/g, "\\,");
const eventUrl = `https://ghostguild.org/events/${evt.slug || evt._id}`;
const ical = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//Ghost Guild//Events//EN",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
"BEGIN:VEVENT",
`UID:${evt._id}@ghostguild.org`,
`DTSTAMP:${timestamp}`,
`DTSTART:${dtstart}`,
`DTEND:${dtend}`,
`SUMMARY:${evt.title}`,
`DESCRIPTION:${description}\\n\\nView event: ${eventUrl}`,
`LOCATION:${evt.location || "Online"}`,
`URL:${eventUrl}`,
`STATUS:CONFIRMED`,
"END:VEVENT",
"END:VCALENDAR",
];
return ical.join("\r\n");
}

View file

@ -1,5 +1,8 @@
import Event from "../../../models/event"; import Event from "../../../models/event";
import { sendEventCancellationEmail } from "../../../utils/resend.js"; import {
sendEventCancellationEmail,
sendWaitlistNotificationEmail,
} from "../../../utils/resend.js";
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const id = getRouterParam(event, "id"); const id = getRouterParam(event, "id");
@ -71,6 +74,39 @@ export default defineEventHandler(async (event) => {
console.error("Failed to send cancellation email:", emailError); console.error("Failed to send cancellation email:", emailError);
} }
// Notify waitlisted users if waitlist is enabled and there are entries
if (
eventDoc.tickets?.waitlist?.enabled &&
eventDoc.tickets.waitlist.entries?.length > 0
) {
try {
const eventData = {
title: eventDoc.title,
slug: eventDoc.slug,
_id: eventDoc._id,
startDate: eventDoc.startDate,
endDate: eventDoc.endDate,
location: eventDoc.location,
};
// Notify the first person on the waitlist who hasn't been notified yet
const waitlistEntry = eventDoc.tickets.waitlist.entries.find(
(entry) => !entry.notified
);
if (waitlistEntry) {
await sendWaitlistNotificationEmail(waitlistEntry, eventData);
// Mark as notified
waitlistEntry.notified = true;
await eventDoc.save();
}
} catch (waitlistError) {
// Log error but don't fail the cancellation
console.error("Failed to notify waitlist:", waitlistError);
}
}
return { return {
success: true, success: true,
message: "Registration cancelled successfully", message: "Registration cancelled successfully",

View file

@ -0,0 +1,59 @@
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 {
// Find event by ID or slug
const eventData = await Event.findOne({
$or: [{ _id: id }, { slug: id }],
});
if (!eventData) {
throw createError({
statusCode: 404,
statusMessage: "Event not found",
});
}
// Find and remove from waitlist
const waitlistIndex = eventData.tickets?.waitlist?.entries?.findIndex(
(entry) => entry.email.toLowerCase() === email.toLowerCase()
);
if (waitlistIndex === -1 || waitlistIndex === undefined) {
throw createError({
statusCode: 404,
statusMessage: "You are not on the waitlist for this event",
});
}
eventData.tickets.waitlist.entries.splice(waitlistIndex, 1);
await eventData.save();
return {
success: true,
message: "You have been removed from the waitlist",
};
} catch (error) {
if (error.statusCode) {
throw error;
}
console.error("Error leaving waitlist:", error);
throw createError({
statusCode: 500,
statusMessage: "Failed to leave waitlist",
});
}
});

View file

@ -0,0 +1,119 @@
import Event from "../../../models/event";
import Member from "../../../models/member";
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, "id");
const body = await readBody(event);
const { name, email, membershipLevel } = body;
if (!email) {
throw createError({
statusCode: 400,
statusMessage: "Email is required",
});
}
try {
// Find event by ID or slug
const eventData = await Event.findOne({
$or: [{ _id: id }, { slug: id }],
});
if (!eventData) {
throw createError({
statusCode: 404,
statusMessage: "Event not found",
});
}
// Check if waitlist is enabled
if (!eventData.tickets?.waitlist?.enabled) {
throw createError({
statusCode: 400,
statusMessage: "Waitlist is not enabled for this event",
});
}
// Check if already on waitlist
const existingEntry = eventData.tickets.waitlist.entries?.find(
(entry) => entry.email.toLowerCase() === email.toLowerCase()
);
if (existingEntry) {
throw createError({
statusCode: 400,
statusMessage: "You are already on the waitlist for this event",
});
}
// Check if already registered
const existingRegistration = eventData.registrations?.find(
(reg) => reg.email?.toLowerCase() === email.toLowerCase() && !reg.cancelledAt
);
if (existingRegistration) {
throw createError({
statusCode: 400,
statusMessage: "You are already registered for this event",
});
}
// Check waitlist capacity
const currentWaitlistSize = eventData.tickets.waitlist.entries?.length || 0;
const maxSize = eventData.tickets.waitlist.maxSize;
if (maxSize && currentWaitlistSize >= maxSize) {
throw createError({
statusCode: 400,
statusMessage: "The waitlist is full",
});
}
// Get member info if authenticated
let memberName = name;
let memberLevel = membershipLevel || "non-member";
// Try to find member by email
const member = await Member.findOne({ email: email.toLowerCase() });
if (member) {
memberName = memberName || member.name;
memberLevel = `${member.circle}-${member.contributionTier}`;
}
// Add to waitlist
if (!eventData.tickets.waitlist.entries) {
eventData.tickets.waitlist.entries = [];
}
eventData.tickets.waitlist.entries.push({
name: memberName || "Guest",
email: email.toLowerCase(),
membershipLevel: memberLevel,
addedAt: new Date(),
notified: false,
});
await eventData.save();
// Get position in waitlist
const position = eventData.tickets.waitlist.entries.length;
return {
success: true,
message: "You have been added to the waitlist",
position,
totalWaitlisted: position,
};
} catch (error) {
if (error.statusCode) {
throw error;
}
console.error("Error joining waitlist:", error);
throw createError({
statusCode: 500,
statusMessage: "Failed to join waitlist",
});
}
});

View file

@ -1,53 +1,53 @@
import Event from '../../models/event.js' import Event from "../../models/event.js";
import { connectDB } from '../../utils/mongoose.js' import { connectDB } from "../../utils/mongoose.js";
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
try { try {
// Ensure database connection // Ensure database connection
await connectDB() await connectDB();
// Get query parameters for filtering // Get query parameters for filtering
const query = getQuery(event) const query = getQuery(event);
const filter = {} const filter = {};
// Only show visible events on public calendar (unless specifically requested) // Only show visible events on public calendar (unless specifically requested)
if (query.includeHidden !== 'true') { if (query.includeHidden !== "true") {
filter.isVisible = true filter.isVisible = true;
} }
// Filter for upcoming events only if requested // Filter for upcoming events only if requested
if (query.upcoming === 'true') { if (query.upcoming === "true") {
filter.startDate = { $gte: new Date() } filter.startDate = { $gte: new Date() };
} }
// Filter by event type if provided // Filter by event type if provided
if (query.eventType) { if (query.eventType) {
filter.eventType = query.eventType filter.eventType = query.eventType;
} }
// Filter for members-only events // Filter for members-only events
if (query.membersOnly !== undefined) { if (query.membersOnly !== undefined) {
filter.membersOnly = query.membersOnly === 'true' filter.membersOnly = query.membersOnly === "true";
} }
// Fetch events from database // Fetch events from database
const events = await Event.find(filter) const events = await Event.find(filter)
.sort({ startDate: 1 }) .sort({ startDate: 1 })
.select('-registrations') // Don't expose registration details in list view .select("-registrations") // Don't expose registration details in list view
.lean() .lean();
// Add computed fields // Add computed fields
const eventsWithMeta = events.map(event => ({ const eventsWithMeta = events.map((event) => ({
...event, ...event,
id: event._id.toString(), id: event._id.toString(),
registeredCount: event.registrations?.length || 0 registeredCount: event.registrations?.length || 0,
})) }));
return eventsWithMeta return eventsWithMeta;
} catch (error) { } catch (error) {
console.error('Error fetching events:', error) console.error("Error fetching events:", error);
throw createError({ throw createError({
statusCode: 500, statusCode: 500,
statusMessage: 'Failed to fetch events' statusMessage: "Failed to fetch events",
}) });
} }
}) });

View file

@ -301,6 +301,185 @@ export async function sendEventCancellationEmail(registration, eventData) {
} }
} }
/**
* Send waitlist notification email when a spot opens up
*/
export async function sendWaitlistNotificationEmail(waitlistEntry, eventData) {
const formatDate = (dateString) => {
const date = new Date(dateString);
return new Intl.DateTimeFormat("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
}).format(date);
};
const formatTime = (startDate, endDate) => {
const start = new Date(startDate);
const end = new Date(endDate);
const timeFormat = new Intl.DateTimeFormat("en-US", {
hour: "numeric",
minute: "2-digit",
timeZoneName: "short",
});
return `${timeFormat.format(start)} - ${timeFormat.format(end)}`;
};
try {
const { data, error } = await resend.emails.send({
from: "Ghost Guild <events@ghostguild.org>",
to: [waitlistEntry.email],
subject: `A spot opened up for ${eventData.title}!`,
html: `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
color: #fff;
padding: 30px;
text-align: center;
border-radius: 8px 8px 0 0;
}
.content {
background-color: #f9f9f9;
padding: 30px;
border-radius: 0 0 8px 8px;
}
.event-details {
background-color: #fff;
padding: 20px;
border-radius: 8px;
margin: 20px 0;
border-left: 4px solid #f59e0b;
}
.detail-row {
margin: 10px 0;
}
.label {
font-weight: 600;
color: #666;
font-size: 14px;
}
.value {
color: #1a1a2e;
font-size: 16px;
}
.button {
display: inline-block;
background-color: #f59e0b;
color: #fff;
padding: 14px 32px;
text-decoration: none;
border-radius: 6px;
margin: 20px 0;
font-weight: 600;
font-size: 16px;
}
.urgent {
background-color: #fef3c7;
border: 2px solid #f59e0b;
padding: 15px;
border-radius: 8px;
margin: 20px 0;
}
.footer {
text-align: center;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #ddd;
color: #666;
font-size: 14px;
}
</style>
</head>
<body>
<div class="header">
<h1 style="margin: 0;">A Spot Just Opened Up! 🎉</h1>
</div>
<div class="content">
<p>Hi ${waitlistEntry.name},</p>
<p>Great news! A spot has become available for <strong>${eventData.title}</strong>, and you're on the waitlist.</p>
<div class="urgent">
<p style="margin: 0; font-weight: 600; color: #92400e;">
Act fast! Spots are filled on a first-come, first-served basis.
</p>
</div>
<div class="event-details">
<div class="detail-row">
<div class="label">Event</div>
<div class="value">${eventData.title}</div>
</div>
<div class="detail-row">
<div class="label">Date</div>
<div class="value">${formatDate(eventData.startDate)}</div>
</div>
<div class="detail-row">
<div class="label">Time</div>
<div class="value">${formatTime(eventData.startDate, eventData.endDate)}</div>
</div>
<div class="detail-row">
<div class="label">Location</div>
<div class="value">${eventData.location}</div>
</div>
</div>
<center>
<a href="${process.env.BASE_URL || "https://ghostguild.org"}/events/${eventData.slug || eventData._id}" class="button">
Register Now
</a>
</center>
<p style="margin-top: 30px; font-size: 14px; color: #666;">
If you can no longer attend, no worries! Just ignore this email and the spot will go to the next person on the waitlist.
</p>
</div>
<div class="footer">
<p>Ghost Guild</p>
<p>
Questions? Email us at
<a href="mailto:events@ghostguild.org">events@ghostguild.org</a>
</p>
</div>
</body>
</html>
`,
});
if (error) {
console.error("Failed to send waitlist notification email:", error);
return { success: false, error };
}
return { success: true, data };
} catch (error) {
console.error("Error sending waitlist notification email:", error);
return { success: false, error };
}
}
/** /**
* Send series pass confirmation email * Send series pass confirmation email
*/ */