Many an update!
This commit is contained in:
parent
85195d6c7a
commit
d588c49946
35 changed files with 3528 additions and 1142 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -22,3 +22,5 @@ logs
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
/*.md
|
||||||
|
scripts/*.js
|
||||||
|
|
|
||||||
|
|
@ -3,5 +3,6 @@
|
||||||
<NuxtLayout>
|
<NuxtLayout>
|
||||||
<NuxtPage />
|
<NuxtPage />
|
||||||
</NuxtLayout>
|
</NuxtLayout>
|
||||||
|
<LoginModal />
|
||||||
</UApp>
|
</UApp>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
201
app/components/LoginModal.vue
Normal file
201
app/components/LoginModal.vue
Normal 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>
|
||||||
126
app/components/MemberStatusBanner.vue
Normal file
126
app/components/MemberStatusBanner.vue
Normal 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>
|
||||||
13
app/components/MemberStatusIndicator.vue
Normal file
13
app/components/MemberStatusIndicator.vue
Normal 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>
|
||||||
83
app/composables/useCalendarSearch.js
Normal file
83
app/composables/useCalendarSearch.js
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
89
app/composables/useEventDateUtils.js
Normal file
89
app/composables/useEventDateUtils.js
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -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...");
|
||||||
|
|
|
||||||
30
app/composables/useLoginModal.js
Normal file
30
app/composables/useLoginModal.js
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
166
app/composables/useMemberPayment.js
Normal file
166
app/composables/useMemberPayment.js
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
155
app/composables/useMemberStatus.js
Normal file
155
app/composables/useMemberStatus.js
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
</p>
|
commons, more folks unable to participate in the program can benefit
|
||||||
|
from collectively compiled knowledge and find community.
|
||||||
<ul
|
</p>
|
||||||
class="list-disc pl-6 text-lg leading-relaxed text-[--ui-text-muted] space-y-3 mb-12"
|
<p>
|
||||||
>
|
something here about the work to make Slack integration smooth and
|
||||||
<li>
|
safe; more about purpose??
|
||||||
The entire knowledge commons, all events, and full community
|
</p>
|
||||||
participation on our private Slack
|
<p>
|
||||||
</li>
|
We are pretty interested in saying _fuck you_ to hierarchy however
|
||||||
<li>One member, one vote in all decisions</li>
|
it shows up in our work. So the Ghost Guild membership program is
|
||||||
<li>Pay what you can ($0-50+/month)</li>
|
tier-less… but peer-full. We've loosely named some circles you can
|
||||||
<li>Contribute your skills, time, and knowledge</li>
|
join that will help us connect you with folks at the same stage of
|
||||||
</ul>
|
development as you, and with resources that are in line with your
|
||||||
</div>
|
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>
|
||||||
</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
213
app/pages/about/circles.vue
Normal 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>
|
||||||
|
|
@ -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
|
<div v-else-if="memberData && !canRSVP" class="text-center">
|
||||||
v-else-if="memberData && (!event.membersOnly || isMember)"
|
<div
|
||||||
class="text-center"
|
:class="[
|
||||||
>
|
'p-6 rounded-lg border mb-6',
|
||||||
<p class="text-lg text-ghost-200 mb-6">
|
statusConfig.bgColor,
|
||||||
You are logged in, {{ memberData.name }}.
|
statusConfig.borderColor,
|
||||||
</p>
|
]"
|
||||||
<UButton
|
|
||||||
color="primary"
|
|
||||||
size="xl"
|
|
||||||
@click="handleRegistration"
|
|
||||||
:loading="isRegistering"
|
|
||||||
class="px-12 py-4"
|
|
||||||
>
|
>
|
||||||
{{ isRegistering ? "Registering..." : "Register Now" }}
|
<Icon
|
||||||
</UButton>
|
: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>
|
||||||
|
<UButton
|
||||||
|
v-if="isPendingPayment"
|
||||||
|
color="orange"
|
||||||
|
size="lg"
|
||||||
|
class="px-8"
|
||||||
|
:loading="isProcessingPayment"
|
||||||
|
@click="completePayment"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
isProcessingPayment ? "Processing..." : "Complete Payment"
|
||||||
|
}}
|
||||||
|
</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++;
|
||||||
|
|
|
||||||
|
|
@ -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,48 +178,49 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #calendar>
|
<template #calendar>
|
||||||
<div class="pt-8">
|
<div class="pt-8 pb-8">
|
||||||
<ClientOnly>
|
<div class="max-w-6xl mx-auto" id="event-calendar">
|
||||||
<div
|
<ClientOnly>
|
||||||
v-if="pending"
|
|
||||||
class="min-h-[400px] bg-ghost-700 rounded-xl flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<div class="text-center">
|
|
||||||
<p class="text-ghost-200">Loading events...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<VueCal
|
|
||||||
v-else
|
|
||||||
:events="events"
|
|
||||||
:time="false"
|
|
||||||
active-view="month"
|
|
||||||
class="custom-calendar"
|
|
||||||
:disable-views="['years', 'year']"
|
|
||||||
:hide-weekends="false"
|
|
||||||
today-button
|
|
||||||
events-on-month-view="short"
|
|
||||||
:editable-events="{
|
|
||||||
title: false,
|
|
||||||
drag: false,
|
|
||||||
resize: false,
|
|
||||||
delete: false,
|
|
||||||
create: false,
|
|
||||||
}"
|
|
||||||
@event-click="onEventClick"
|
|
||||||
/>
|
|
||||||
<template #fallback>
|
|
||||||
<div
|
<div
|
||||||
class="min-h-[400px] bg-ghost-700 rounded-xl flex items-center justify-center"
|
v-if="pending"
|
||||||
|
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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
<div v-else style="min-height: 600px">
|
||||||
</ClientOnly>
|
<VueCal
|
||||||
|
ref="vuecal"
|
||||||
|
:events="calendarEvents"
|
||||||
|
:time="false"
|
||||||
|
active-view="month"
|
||||||
|
class="ghost-calendar"
|
||||||
|
:disable-views="['years', 'year']"
|
||||||
|
:hide-view-selector="false"
|
||||||
|
events-on-month-view="short"
|
||||||
|
today-button
|
||||||
|
@event-click="onEventClick"
|
||||||
|
>
|
||||||
|
</VueCal>
|
||||||
|
</div>
|
||||||
|
<template #fallback>
|
||||||
|
<div
|
||||||
|
class="min-h-[400px] bg-ghost-800 rounded-xl flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<div class="text-center">
|
||||||
|
<div
|
||||||
|
class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"
|
||||||
|
></div>
|
||||||
|
<p class="text-ghost-200">Loading calendar...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</UTabs>
|
</UTabs>
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 ($0–50+/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");
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
281
app/pages/welcome.vue
Normal 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>
|
||||||
228
scripts/add-cooperative-values-events.js
Normal file
228
scripts/add-cooperative-values-events.js
Normal 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();
|
||||||
203
scripts/add-module-events-to-atlas.js
Normal file
203
scripts/add-module-events-to-atlas.js
Normal 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();
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
103
server/api/events/[id]/calendar.get.js
Normal file
103
server/api/events/[id]/calendar.get.js
Normal 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");
|
||||||
|
}
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
59
server/api/events/[id]/waitlist.delete.js
Normal file
59
server/api/events/[id]/waitlist.delete.js
Normal 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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
119
server/api/events/[id]/waitlist.post.js
Normal file
119
server/api/events/[id]/waitlist.post.js
Normal 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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -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",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue