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.example
|
||||
/*.md
|
||||
scripts/*.js
|
||||
|
|
|
|||
|
|
@ -3,5 +3,6 @@
|
|||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
<LoginModal />
|
||||
</UApp>
|
||||
</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;
|
||||
}
|
||||
|
||||
// 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")) {
|
||||
const style = document.createElement("style");
|
||||
style.id = "helcim-overlay-fix";
|
||||
style.textContent = `
|
||||
/* Fix Helcim iframe overlay - the second parameter to appendHelcimPayIframe controls this */
|
||||
/* Target all fixed position divs that might be the overlay */
|
||||
body > div[style*="position: fixed"][style*="inset: 0"],
|
||||
body > div[style*="position:fixed"][style*="inset:0"] {
|
||||
/* Style any fixed position div that Helcim creates */
|
||||
body > div[style] {
|
||||
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);
|
||||
}
|
||||
|
|
@ -186,6 +194,41 @@ export const useHelcimPay = () => {
|
|||
// Add event listener
|
||||
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
|
||||
console.log("Calling appendHelcimPayIframe with token:", checkoutToken);
|
||||
window.appendHelcimPayIframe(checkoutToken, true);
|
||||
|
|
@ -193,6 +236,9 @@ export const useHelcimPay = () => {
|
|||
"appendHelcimPayIframe called, waiting for window messages...",
|
||||
);
|
||||
|
||||
// Clean up observer after a timeout
|
||||
setTimeout(() => observer.disconnect(), 5000);
|
||||
|
||||
// Add timeout to clean up if no response
|
||||
setTimeout(() => {
|
||||
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 () => {
|
||||
try {
|
||||
await $fetch("/api/auth/logout", { method: "POST" });
|
||||
await navigateTo("/login");
|
||||
await navigateTo("/");
|
||||
} catch (error) {
|
||||
console.error("Logout failed:", error);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,24 +4,33 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
|
|||
console.log('🛡️ Auth middleware - skipping on server')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
const { memberData, checkMemberStatus } = useAuth()
|
||||
|
||||
const { openLoginModal } = useLoginModal()
|
||||
|
||||
console.log('🛡️ Auth middleware (CLIENT) - route:', to.path)
|
||||
console.log(' - memberData exists:', !!memberData.value)
|
||||
console.log(' - Running on:', process.server ? 'SERVER' : 'CLIENT')
|
||||
|
||||
|
||||
// If no member data, try to check authentication
|
||||
if (!memberData.value) {
|
||||
console.log(' - No member data, checking authentication...')
|
||||
const isAuthenticated = await checkMemberStatus()
|
||||
console.log(' - Authentication result:', isAuthenticated)
|
||||
|
||||
|
||||
if (!isAuthenticated) {
|
||||
console.log(' - ❌ Authentication failed, redirecting to login')
|
||||
return navigateTo('/login')
|
||||
console.log(' - ❌ Authentication failed, showing login modal')
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
console.log(' - ✅ Authentication successful for:', memberData.value?.email)
|
||||
})
|
||||
|
|
@ -2,206 +2,63 @@
|
|||
<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."
|
||||
title="About Ghost Guild"
|
||||
subtitle=""
|
||||
theme="blue"
|
||||
size="large"
|
||||
/>
|
||||
|
||||
<!-- How Ghost Guild Works -->
|
||||
<!-- Main Content -->
|
||||
<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 Ghost Guild 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 class="max-w-3xl">
|
||||
<!-- TODO: Add copy about history and connection to Baby Ghosts -->
|
||||
<p class="text-lg text-[--ui-text-muted]">
|
||||
Ghost Guild is a community of learning and practice for anyone,
|
||||
anywhere interested in a video game industry-wide shift to
|
||||
worker-owned studio models. It is also the membership program of
|
||||
Baby Ghosts, a Canadian nonprofit that provides resources and
|
||||
support for worker-owned studios. After running our Peer Accelerator
|
||||
program for three years, we are now scaling up our operations to
|
||||
support more studios and expand our reach. As we build our knowledge
|
||||
commons, more folks unable to participate in the program can benefit
|
||||
from collectively compiled knowledge and find community.
|
||||
</p>
|
||||
<p>
|
||||
something here about the work to make Slack integration smooth and
|
||||
safe; more about purpose??
|
||||
</p>
|
||||
<p>
|
||||
We are pretty interested in saying _fuck you_ to hierarchy however
|
||||
it shows up in our work. So the Ghost Guild membership program is
|
||||
tier-less… but peer-full. We've loosely named some circles you can
|
||||
join that will help us connect you with folks at the same stage of
|
||||
development as you, and with resources that are in line with your
|
||||
needs and interests. But none of these circles is superior, and
|
||||
there's no harm or shame in sticking with one for a while, or moving
|
||||
between them to find the best fit. Choosing your financial
|
||||
contribution level is also not about paying for access to additional
|
||||
resources - everything is available to every member, no matter their
|
||||
circle or contribution level. Rather, it's about finding a dues
|
||||
level that's meaningful to you but not a burden.
|
||||
</p>
|
||||
</div>
|
||||
</UContainer>
|
||||
</section>
|
||||
|
||||
<!-- Find Your Circle -->
|
||||
<!-- Link to Membership Circles -->
|
||||
<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
|
||||
<div class="max-w-3xl">
|
||||
<h2 class="text-2xl font-bold text-[--ui-text] mb-4">
|
||||
Membership Circles
|
||||
</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 class="text-lg text-[--ui-text-muted] mb-6">
|
||||
Learn about our three membership circles and find where you fit.
|
||||
</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>
|
||||
<UButton to="/about/circles" variant="outline" size="lg">
|
||||
Explore Membership Circles
|
||||
</UButton>
|
||||
</div>
|
||||
</UContainer>
|
||||
</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">
|
||||
<!-- Event Meta Info -->
|
||||
<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>
|
||||
<p class="text-sm text-ghost-400">Date</p>
|
||||
<p class="font-semibold text-ghost-100">
|
||||
|
|
@ -100,6 +100,20 @@
|
|||
{{ event.location }}
|
||||
</p>
|
||||
</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>
|
||||
|
||||
|
|
@ -270,28 +284,64 @@
|
|||
</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"
|
||||
<!-- Member Status Check (pending payment, suspended, cancelled) -->
|
||||
<div v-else-if="memberData && !canRSVP" class="text-center">
|
||||
<div
|
||||
:class="[
|
||||
'p-6 rounded-lg border mb-6',
|
||||
statusConfig.bgColor,
|
||||
statusConfig.borderColor,
|
||||
]"
|
||||
>
|
||||
{{ isRegistering ? "Registering..." : "Register Now" }}
|
||||
</UButton>
|
||||
<Icon
|
||||
: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>
|
||||
|
||||
<!-- Member Gate Warning -->
|
||||
<!-- Member Gate Warning (members-only locked event) -->
|
||||
<div
|
||||
v-else-if="event.membersOnly && !isMember"
|
||||
v-else-if="event.membersOnly && memberData && !isMember"
|
||||
class="text-center"
|
||||
>
|
||||
<div
|
||||
|
|
@ -312,6 +362,25 @@
|
|||
</NuxtLink>
|
||||
</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 -->
|
||||
<div v-else>
|
||||
<h3 class="text-xl font-bold text-ghost-100 mb-6">
|
||||
|
|
@ -403,6 +472,64 @@
|
|||
</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>
|
||||
|
||||
|
|
@ -446,16 +573,21 @@ if (error.value?.statusCode === 404) {
|
|||
|
||||
// Authentication
|
||||
const { isMember, memberData, checkMemberStatus } = useAuth();
|
||||
const {
|
||||
isPendingPayment,
|
||||
isSuspended,
|
||||
isCancelled,
|
||||
canRSVP,
|
||||
statusConfig,
|
||||
getRSVPMessage,
|
||||
} = useMemberStatus();
|
||||
const { completePayment, isProcessingPayment, paymentError } =
|
||||
useMemberPayment();
|
||||
|
||||
// Check member status on mount
|
||||
onMounted(async () => {
|
||||
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
|
||||
if (memberData.value) {
|
||||
registrationForm.value.name = memberData.value.name;
|
||||
|
|
@ -465,6 +597,9 @@ onMounted(async () => {
|
|||
|
||||
// Check if user is already registered
|
||||
await checkRegistrationStatus();
|
||||
|
||||
// Check waitlist status
|
||||
checkWaitlistStatus();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -507,6 +642,105 @@ const isRegistering = ref(false);
|
|||
const isCancelling = ref(false);
|
||||
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
|
||||
const formatDate = (dateString) => {
|
||||
const date = new Date(dateString);
|
||||
|
|
@ -671,7 +905,6 @@ const handleCancelRegistration = async () => {
|
|||
|
||||
// Handle ticket purchase success
|
||||
const handleTicketSuccess = (response) => {
|
||||
console.log("Ticket purchased successfully:", response);
|
||||
// Update registered count if needed
|
||||
if (event.value.registeredCount !== undefined) {
|
||||
event.value.registeredCount++;
|
||||
|
|
|
|||
|
|
@ -13,13 +13,115 @@
|
|||
<UTabs
|
||||
v-model="activeTab"
|
||||
:items="[
|
||||
{ label: 'Upcoming Events', value: 'upcoming', slot: 'upcoming' },
|
||||
{ label: 'What\'s On', value: 'upcoming', slot: 'upcoming' },
|
||||
{ label: 'Calendar', value: 'calendar', slot: 'calendar' },
|
||||
]"
|
||||
class="max-w-6xl mx-auto"
|
||||
>
|
||||
<template #upcoming>
|
||||
<div class="max-w-4xl mx-auto space-y-6 pt-8">
|
||||
<!-- 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
|
||||
v-for="event in upcomingEvents"
|
||||
:key="event.id"
|
||||
|
|
@ -76,48 +178,49 @@
|
|||
</template>
|
||||
|
||||
<template #calendar>
|
||||
<div class="pt-8">
|
||||
<ClientOnly>
|
||||
<div
|
||||
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 class="pt-8 pb-8">
|
||||
<div class="max-w-6xl mx-auto" id="event-calendar">
|
||||
<ClientOnly>
|
||||
<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="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>
|
||||
<p class="text-ghost-200">Loading calendar...</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
<div v-else style="min-height: 600px">
|
||||
<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>
|
||||
</template>
|
||||
</UTabs>
|
||||
|
|
@ -183,7 +286,7 @@
|
|||
<div class="series-list-item__events space-y-2 mb-4">
|
||||
<div
|
||||
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"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
|
|
@ -234,28 +337,16 @@
|
|||
>
|
||||
<div class="prose prose-lg dark:prose-invert max-w-none">
|
||||
<p class="text-lg leading-relaxed text-ghost-200 mb-6">
|
||||
Our events are ,Lorem ipsum, dolor sit amet consectetur
|
||||
adipisicing elit. Quibusdam exercitationem delectus ab
|
||||
voluptates aspernatur, quia deleniti aut maxime, veniam
|
||||
accusantium non dolores saepe error, ipsam laudantium asperiores
|
||||
dolorum alias nulla!
|
||||
Our events bring together game developers, founders, and practitioners
|
||||
who are building more equitable workplaces. From hands-on workshops
|
||||
about governance and finance to casual co-working sessions and game nights,
|
||||
there's something for every stage of your journey.
|
||||
</p>
|
||||
|
||||
<p class="text-lg leading-relaxed text-ghost-200 mb-6">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do
|
||||
eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
|
||||
enim ad minim veniam, quis nostrud exercitation ullamco laboris
|
||||
nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor
|
||||
in reprehenderit in voluptate velit esse cillum dolore eu fugiat
|
||||
nulla pariatur. Excepteur sint occaecat cupidatat non proident,
|
||||
sunt in culpa qui officia deserunt mollit anim id est laborum.
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do
|
||||
eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
|
||||
enim ad minim veniam, quis nostrud exercitation ullamco laboris
|
||||
nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor
|
||||
in reprehenderit in voluptate velit esse cillum dolore eu fugiat
|
||||
nulla pariatur. Excepteur sint occaecat cupidatat non proident,
|
||||
sunt in culpa qui officia deserunt mollit anim id est laborum.
|
||||
All events are designed to be accessible, with most offered free to members
|
||||
and sliding-scale pricing for non-members. Can't make it live?
|
||||
Many sessions are recorded and shared in our resource library.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -301,19 +392,64 @@
|
|||
import { VueCal } from "vue-cal";
|
||||
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
|
||||
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
|
||||
const { data: eventsData, pending, error } = await useFetch("/api/events");
|
||||
// Fetch series from API
|
||||
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
|
||||
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,
|
||||
slug: event.slug,
|
||||
start: new Date(event.startDate),
|
||||
|
|
@ -329,6 +465,71 @@ const events = computed(() => {
|
|||
featureImage: event.featureImage,
|
||||
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
|
||||
|
|
@ -340,13 +541,13 @@ const activeSeries = computed(() => {
|
|||
);
|
||||
});
|
||||
|
||||
// Get upcoming events (future events)
|
||||
// Get upcoming events (future events, respecting filters)
|
||||
const upcomingEvents = computed(() => {
|
||||
const now = new Date();
|
||||
return events.value
|
||||
return filteredEvents.value
|
||||
.filter((event) => event.start > now)
|
||||
.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
|
||||
|
|
@ -426,30 +627,6 @@ const getSeriesTypeBadgeClass = (type) => {
|
|||
"bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400"
|
||||
);
|
||||
};
|
||||
|
||||
const formatDateRange = (startDate, endDate) => {
|
||||
if (!startDate || !endDate) return "No dates";
|
||||
|
||||
const start = new Date(startDate);
|
||||
const end = new Date(endDate);
|
||||
|
||||
const startMonth = start.toLocaleDateString("en-US", { month: "short" });
|
||||
const endMonth = end.toLocaleDateString("en-US", { month: "short" });
|
||||
const startDay = start.getDate();
|
||||
const endDay = end.getDate();
|
||||
const year = end.getFullYear();
|
||||
|
||||
if (
|
||||
start.getMonth() === end.getMonth() &&
|
||||
start.getFullYear() === end.getFullYear()
|
||||
) {
|
||||
return `${startMonth} ${startDay}-${endDay}, ${year}`;
|
||||
} else if (start.getFullYear() === end.getFullYear()) {
|
||||
return `${startMonth} ${startDay} - ${endMonth} ${endDay}, ${year}`;
|
||||
} else {
|
||||
return `${formatEventDate(startDate)} - ${formatEventDate(endDate)}`;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -460,8 +637,199 @@ const formatDateRange = (startDate, endDate) => {
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Custom calendar styling to match the site theme */
|
||||
.custom-calendar {
|
||||
/* Calendar styling based on tranzac design principles */
|
||||
.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-text-color: #e7e5e4;
|
||||
--vuecal-border-color: #57534e;
|
||||
|
|
@ -470,142 +838,124 @@ const formatDateRange = (startDate, endDate) => {
|
|||
background-color: #292524;
|
||||
}
|
||||
|
||||
.custom-calendar :deep(.vuecal__bg) {
|
||||
.ghost-calendar :deep(.vuecal__bg) {
|
||||
background-color: #292524;
|
||||
}
|
||||
|
||||
.custom-calendar :deep(.vuecal__header) {
|
||||
.ghost-calendar :deep(.vuecal__header) {
|
||||
background-color: #1c1917;
|
||||
border-bottom: 1px solid #57534e;
|
||||
}
|
||||
|
||||
.custom-calendar :deep(.vuecal__title-bar) {
|
||||
.ghost-calendar :deep(.vuecal__title-bar) {
|
||||
background-color: #1c1917;
|
||||
}
|
||||
|
||||
.custom-calendar :deep(.vuecal__title) {
|
||||
.ghost-calendar :deep(.vuecal__title) {
|
||||
color: #e7e5e4;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.custom-calendar :deep(.vuecal__weekdays-headings) {
|
||||
.ghost-calendar :deep(.vuecal__weekdays-headings) {
|
||||
background-color: #1c1917;
|
||||
border-bottom: 1px solid #57534e;
|
||||
}
|
||||
|
||||
.custom-calendar :deep(.vuecal__heading) {
|
||||
.ghost-calendar :deep(.vuecal__heading) {
|
||||
color: #a8a29e;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.custom-calendar :deep(.vuecal__cell) {
|
||||
.ghost-calendar :deep(.vuecal__cell) {
|
||||
background-color: #292524;
|
||||
border-color: #57534e;
|
||||
color: #e7e5e4;
|
||||
}
|
||||
|
||||
.custom-calendar :deep(.vuecal__cell:hover) {
|
||||
.ghost-calendar :deep(.vuecal__cell:hover) {
|
||||
background-color: #44403c;
|
||||
border-color: #78716c;
|
||||
}
|
||||
|
||||
.custom-calendar :deep(.vuecal__cell-content) {
|
||||
.ghost-calendar :deep(.vuecal__cell-content) {
|
||||
color: #e7e5e4;
|
||||
}
|
||||
|
||||
.custom-calendar :deep(.vuecal__cell--today) {
|
||||
background-color: #44403c;
|
||||
.ghost-calendar :deep(.vuecal__cell--today) {
|
||||
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;
|
||||
color: #78716c;
|
||||
}
|
||||
|
||||
.custom-calendar :deep(.vuecal__arrow) {
|
||||
.ghost-calendar :deep(.vuecal__arrow) {
|
||||
color: #a8a29e;
|
||||
}
|
||||
|
||||
.custom-calendar :deep(.vuecal__arrow:hover) {
|
||||
.ghost-calendar :deep(.vuecal__arrow:hover) {
|
||||
background-color: #44403c;
|
||||
}
|
||||
|
||||
.custom-calendar :deep(.vuecal__today-btn) {
|
||||
.ghost-calendar :deep(.vuecal__today-btn) {
|
||||
background-color: #44403c;
|
||||
color: white;
|
||||
color: #fff;
|
||||
border: 1px solid #78716c;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.custom-calendar :deep(.vuecal__today-btn:hover) {
|
||||
.ghost-calendar :deep(.vuecal__today-btn:hover) {
|
||||
background-color: #57534e;
|
||||
border-color: #a8a29e;
|
||||
}
|
||||
|
||||
.custom-calendar :deep(.vuecal__view-btn),
|
||||
.custom-calendar :deep(button[class*="view"]) {
|
||||
.ghost-calendar :deep(.vuecal__view-btn),
|
||||
.ghost-calendar :deep(button[class*="view"]) {
|
||||
background-color: #44403c !important;
|
||||
color: #ffffff !important;
|
||||
border: 1px solid #78716c !important;
|
||||
font-weight: 600 !important;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.custom-calendar :deep(.vuecal__view-btn:hover),
|
||||
.custom-calendar :deep(button[class*="view"]:hover) {
|
||||
.ghost-calendar :deep(.vuecal__view-btn:hover),
|
||||
.ghost-calendar :deep(button[class*="view"]:hover) {
|
||||
background-color: #57534e !important;
|
||||
border-color: #a8a29e !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.custom-calendar :deep(.vuecal__view-btn--active),
|
||||
.custom-calendar :deep(button[class*="view"][class*="active"]) {
|
||||
.ghost-calendar :deep(.vuecal__view-btn--active),
|
||||
.ghost-calendar :deep(button[class*="view"][class*="active"]) {
|
||||
background-color: #0c0a09 !important;
|
||||
color: #ffffff !important;
|
||||
border-color: #a8a29e !important;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.custom-calendar :deep(.vuecal__view-btn--active:hover),
|
||||
.custom-calendar :deep(button[class*="view"][class*="active"]:hover) {
|
||||
.ghost-calendar :deep(.vuecal__view-btn--active:hover),
|
||||
.ghost-calendar :deep(button[class*="view"][class*="active"]:hover) {
|
||||
background-color: #1c1917 !important;
|
||||
border-color: #d6d3d1 !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.custom-calendar :deep(.vuecal__title-bar button) {
|
||||
.ghost-calendar :deep(.vuecal__title-bar button) {
|
||||
color: #ffffff !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;
|
||||
color: #ffffff !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;
|
||||
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 */
|
||||
.vuecal {
|
||||
border-radius: 0.75rem;
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
<!-- Floating subtitle -->
|
||||
<div class="mb-16">
|
||||
<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
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -87,13 +87,14 @@
|
|||
|
||||
<div class="max-w-2xl">
|
||||
<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 class="text-ghost-400 leading-relaxed ml-8">
|
||||
Sed do eiusmod tempor incididunt ut labore et dolore magna
|
||||
aliqua.<br />
|
||||
Ut enim ad minim veniam, quis nostrud exercitation.
|
||||
There's space for you no matter where you are in your cooperative
|
||||
journey and no matter where in the world you are! You'll find peers,
|
||||
resources, and support here.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<PageHeader
|
||||
v-if="!isAuthenticated"
|
||||
title="Join Ghost Guild"
|
||||
subtitle="Become a member of our community and start building a more worker-centric future for games."
|
||||
subtitle=""
|
||||
theme="gray"
|
||||
size="large"
|
||||
/>
|
||||
|
|
@ -16,6 +16,27 @@
|
|||
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 -->
|
||||
<section v-if="!isAuthenticated" class="py-20 bg-[--ui-bg]">
|
||||
<UContainer class="max-w-4xl">
|
||||
|
|
@ -23,11 +44,6 @@
|
|||
<h2 class="text-3xl font-bold text-[--ui-text] mb-4">
|
||||
Membership Sign Up
|
||||
</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>
|
||||
|
||||
<!-- Step Indicators -->
|
||||
|
|
@ -385,123 +401,6 @@
|
|||
</div>
|
||||
</UContainer>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
|
|
@ -598,7 +497,6 @@ const handleSubmit = async () => {
|
|||
});
|
||||
|
||||
if (response.success) {
|
||||
console.log("Customer response:", response);
|
||||
customerId.value = response.customerId;
|
||||
customerCode.value = response.customerCode;
|
||||
|
||||
|
|
@ -608,13 +506,6 @@ const handleSubmit = async () => {
|
|||
// Move to next step
|
||||
if (needsPayment.value) {
|
||||
currentStep.value = 2;
|
||||
// Debug log
|
||||
console.log(
|
||||
"Customer ID:",
|
||||
customerId.value,
|
||||
"Customer Code:",
|
||||
customerCode.value,
|
||||
);
|
||||
// Initialize HelcimPay.js session for card verification
|
||||
await initializeHelcimPay(customerId.value, customerCode.value, 0);
|
||||
} else {
|
||||
|
|
@ -623,9 +514,9 @@ const handleSubmit = async () => {
|
|||
// Check member status to ensure user is properly authenticated
|
||||
await checkMemberStatus();
|
||||
|
||||
// Automatically redirect to dashboard after a short delay
|
||||
// Automatically redirect to welcome page after a short delay
|
||||
setTimeout(() => {
|
||||
navigateTo("/member/dashboard");
|
||||
navigateTo("/welcome");
|
||||
}, 3000); // 3 second delay to show success message
|
||||
}
|
||||
}
|
||||
|
|
@ -642,21 +533,16 @@ const handleSubmit = async () => {
|
|||
const processPayment = async () => {
|
||||
if (isSubmitting.value) return;
|
||||
|
||||
console.log("Starting payment process...");
|
||||
isSubmitting.value = true;
|
||||
errorMessage.value = "";
|
||||
|
||||
try {
|
||||
console.log("Calling verifyPayment()...");
|
||||
// Verify payment through HelcimPay.js
|
||||
const paymentResult = await verifyPayment();
|
||||
console.log("Payment result from HelcimPay:", paymentResult);
|
||||
|
||||
if (paymentResult.success) {
|
||||
paymentToken.value = paymentResult.cardToken;
|
||||
console.log("Payment successful, cardToken:", paymentResult.cardToken);
|
||||
|
||||
console.log("Calling verify-payment endpoint...");
|
||||
// Verify payment on server
|
||||
const verifyResult = await $fetch("/api/helcim/verify-payment", {
|
||||
method: "POST",
|
||||
|
|
@ -665,9 +551,7 @@ const processPayment = async () => {
|
|||
customerId: customerId.value,
|
||||
},
|
||||
});
|
||||
console.log("Payment verification result:", verifyResult);
|
||||
|
||||
console.log("Calling createSubscription...");
|
||||
// Create subscription (don't let subscription errors prevent form progression)
|
||||
const subscriptionResult = await createSubscription(
|
||||
paymentResult.cardToken,
|
||||
|
|
@ -686,12 +570,6 @@ const processPayment = async () => {
|
|||
}
|
||||
} catch (error) {
|
||||
console.error("Payment process error:", error);
|
||||
console.error("Error details:", {
|
||||
message: error.message,
|
||||
statusCode: error.statusCode,
|
||||
statusMessage: error.statusMessage,
|
||||
data: error.data,
|
||||
});
|
||||
errorMessage.value =
|
||||
error.message || "Payment verification failed. Please try again.";
|
||||
} finally {
|
||||
|
|
@ -702,12 +580,6 @@ const processPayment = async () => {
|
|||
// Create subscription
|
||||
const createSubscription = async (cardToken = null) => {
|
||||
try {
|
||||
console.log("Creating subscription with:", {
|
||||
customerId: customerId.value,
|
||||
contributionTier: form.contributionTier,
|
||||
cardToken: cardToken ? "present" : "null",
|
||||
});
|
||||
|
||||
const response = await $fetch("/api/helcim/subscription", {
|
||||
method: "POST",
|
||||
body: {
|
||||
|
|
@ -718,20 +590,17 @@ const createSubscription = async (cardToken = null) => {
|
|||
},
|
||||
});
|
||||
|
||||
console.log("Subscription creation response:", response);
|
||||
|
||||
if (response.success) {
|
||||
subscriptionData.value = response.subscription;
|
||||
console.log("Moving to step 3 - success!");
|
||||
currentStep.value = 3;
|
||||
successMessage.value = "Your membership has been activated successfully!";
|
||||
|
||||
// Check member status to ensure user is properly authenticated
|
||||
await checkMemberStatus();
|
||||
|
||||
// Automatically redirect to dashboard after a short delay
|
||||
// Automatically redirect to welcome page after a short delay
|
||||
setTimeout(() => {
|
||||
navigateTo("/member/dashboard");
|
||||
navigateTo("/welcome");
|
||||
}, 3000); // 3 second delay to show success message
|
||||
} else {
|
||||
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="">
|
||||
<!-- Loading State -->
|
||||
<div
|
||||
v-if="!memberData || authPending"
|
||||
v-if="authPending"
|
||||
class="flex justify-center items-center py-20"
|
||||
>
|
||||
<div class="text-center">
|
||||
|
|
@ -22,8 +22,27 @@
|
|||
</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 -->
|
||||
<div v-else class="space-y-8">
|
||||
<!-- Member Status Banner -->
|
||||
<MemberStatusBanner :dismissible="true" />
|
||||
<!-- Welcome Card -->
|
||||
<UCard
|
||||
class="sparkle-field"
|
||||
|
|
@ -39,7 +58,16 @@
|
|||
<h1 class="text-2xl font-bold text-ghost-100 ethereal-text">
|
||||
Welcome to Ghost Guild, {{ memberData?.name }}!
|
||||
</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 class="flex-shrink-0" v-if="memberData?.avatar">
|
||||
<img
|
||||
|
|
@ -92,18 +120,29 @@
|
|||
<UButton
|
||||
to="/members?peerSupport=true"
|
||||
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
|
||||
:title="
|
||||
!canPeerSupport
|
||||
? 'Complete your membership to book peer sessions'
|
||||
: ''
|
||||
"
|
||||
>
|
||||
Book a Peer Session
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
disabled
|
||||
to="https://wiki.ghostguild.org"
|
||||
target="_blank"
|
||||
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
|
||||
title="Coming soon"
|
||||
>
|
||||
Browse Resources
|
||||
</UButton>
|
||||
|
|
@ -309,6 +348,8 @@
|
|||
|
||||
<script setup>
|
||||
const { memberData, checkMemberStatus } = useAuth();
|
||||
const { isActive, statusConfig, isPendingPayment, canPeerSupport } = useMemberStatus();
|
||||
const { completePayment, isProcessingPayment } = useMemberPayment();
|
||||
|
||||
const registeredEvents = ref([]);
|
||||
const loadingEvents = ref(false);
|
||||
|
|
@ -340,6 +381,8 @@ const copyCalendarLink = async () => {
|
|||
}
|
||||
};
|
||||
|
||||
const { openLoginModal } = useLoginModal();
|
||||
|
||||
// Handle authentication check on page load
|
||||
const { pending: authPending } = await useLazyAsyncData(
|
||||
"dashboard-auth",
|
||||
|
|
@ -347,52 +390,38 @@ const { pending: authPending } = await useLazyAsyncData(
|
|||
// Only check authentication on client side
|
||||
if (process.server) return null;
|
||||
|
||||
console.log(
|
||||
"📊 Dashboard auth check - memberData exists:",
|
||||
!!memberData.value,
|
||||
);
|
||||
|
||||
// If no member data, try to authenticate
|
||||
if (!memberData.value) {
|
||||
console.log(" - No member data, checking authentication...");
|
||||
const isAuthenticated = await checkMemberStatus();
|
||||
console.log(" - Auth result:", isAuthenticated);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
console.log(" - Redirecting to login");
|
||||
await navigateTo("/login");
|
||||
// Show login modal instead of redirecting
|
||||
openLoginModal({
|
||||
title: "Sign in to your dashboard",
|
||||
description: "Enter your email to access your member dashboard",
|
||||
dismissible: true,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(" - ✅ Dashboard auth successful");
|
||||
return memberData.value;
|
||||
},
|
||||
);
|
||||
|
||||
// Load registered events
|
||||
const loadRegisteredEvents = async () => {
|
||||
console.log(
|
||||
"🔍 memberData.value:",
|
||||
JSON.stringify(memberData.value, null, 2),
|
||||
);
|
||||
console.log("🔍 memberData.value._id:", memberData.value?._id);
|
||||
console.log("🔍 memberData.value.id:", memberData.value?.id);
|
||||
|
||||
const memberId = memberData.value?._id || memberData.value?.id;
|
||||
|
||||
if (!memberId) {
|
||||
console.log("❌ No member ID available");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("📅 Loading events for member:", memberId);
|
||||
loadingEvents.value = true;
|
||||
try {
|
||||
const response = await $fetch("/api/members/my-events", {
|
||||
params: { memberId },
|
||||
});
|
||||
console.log("📅 Events response:", response);
|
||||
registeredEvents.value = response.events;
|
||||
} catch (error) {
|
||||
console.error("Failed to load registered events:", error);
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
<section class="py-12 px-4">
|
||||
<UContainer class="px-4">
|
||||
<!-- 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">
|
||||
<span class="text-2xl font-bold text-ghost-100">{{ total }}</span>
|
||||
{{ total === 1 ? "update" : "updates" }} posted
|
||||
|
|
@ -31,6 +31,23 @@
|
|||
</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 -->
|
||||
<div v-else-if="updates.length" class="space-y-6">
|
||||
<UpdateCard
|
||||
|
|
@ -111,6 +128,7 @@
|
|||
|
||||
<script setup>
|
||||
const { isAuthenticated, checkMemberStatus } = useAuth();
|
||||
const { openLoginModal } = useLoginModal();
|
||||
|
||||
const updates = ref([]);
|
||||
const pending = ref(false);
|
||||
|
|
@ -127,7 +145,11 @@ onMounted(async () => {
|
|||
if (!isAuthenticated.value) {
|
||||
const authenticated = await checkMemberStatus();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,8 +9,9 @@
|
|||
|
||||
<section class="py-12">
|
||||
<UContainer>
|
||||
<!-- Loading State -->
|
||||
<div
|
||||
v-if="!memberData || loading"
|
||||
v-if="loading"
|
||||
class="flex justify-center items-center py-20"
|
||||
>
|
||||
<div class="text-center">
|
||||
|
|
@ -23,6 +24,23 @@
|
|||
</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>
|
||||
<UTabs :items="tabItems" v-model="activeTab">
|
||||
<template #profile>
|
||||
|
|
@ -49,7 +67,6 @@
|
|||
<UInput
|
||||
v-model="formData.name"
|
||||
placeholder="Your name"
|
||||
disabled
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
|
|
@ -551,6 +568,9 @@
|
|||
|
||||
<template #account>
|
||||
<div class="space-y-8 mt-8">
|
||||
<!-- Member Status Banner -->
|
||||
<MemberStatusBanner :dismissible="false" />
|
||||
|
||||
<!-- Current Membership -->
|
||||
<div>
|
||||
<h2
|
||||
|
|
@ -562,6 +582,42 @@
|
|||
<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"
|
||||
>
|
||||
<!-- 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>
|
||||
<p class="text-sm text-gray-600 dark:text-ghost-400">
|
||||
|
|
@ -752,6 +808,8 @@
|
|||
|
||||
<script setup>
|
||||
const { memberData, checkMemberStatus } = useAuth();
|
||||
const { openLoginModal } = useLoginModal();
|
||||
const { statusConfig } = useMemberStatus();
|
||||
const route = useRoute();
|
||||
|
||||
// Initialize active tab from URL hash or default to 'profile'
|
||||
|
|
@ -1156,13 +1214,10 @@ const updateContributionLevel = async () => {
|
|||
if (!oldTierRequiresPayment && newTierRequiresPayment) {
|
||||
// Always show payment popup when upgrading from free to paid
|
||||
// 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();
|
||||
console.log("Payment setup completed successfully, returning early");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Calling update-contribution API");
|
||||
// Call API to update contribution
|
||||
await $fetch("/api/members/update-contribution", {
|
||||
method: "POST",
|
||||
|
|
@ -1193,7 +1248,6 @@ const updateContributionLevel = async () => {
|
|||
|
||||
if (requiresPayment) {
|
||||
// Show payment modal
|
||||
console.log("Showing payment modal - payment setup required");
|
||||
try {
|
||||
await handlePaymentSetup();
|
||||
// If successful, return early without showing error
|
||||
|
|
@ -1228,13 +1282,6 @@ const handlePaymentSetup = async () => {
|
|||
|
||||
customerId.value = customerResponse.customerId;
|
||||
customerCode.value = customerResponse.customerCode;
|
||||
|
||||
console.log(
|
||||
customerResponse.existing
|
||||
? "Using existing Helcim customer:"
|
||||
: "Created new Helcim customer:",
|
||||
customerId.value,
|
||||
);
|
||||
} else {
|
||||
// Get customer code from existing customer via server-side endpoint
|
||||
const customerResponse = await $fetch("/api/helcim/customer-code");
|
||||
|
|
@ -1247,7 +1294,6 @@ const handlePaymentSetup = async () => {
|
|||
|
||||
// Show payment modal
|
||||
const paymentResult = await verifyPayment();
|
||||
console.log("Payment result:", paymentResult);
|
||||
|
||||
if (!paymentResult.success) {
|
||||
throw new Error("Payment verification failed");
|
||||
|
|
@ -1328,7 +1374,11 @@ onMounted(async () => {
|
|||
loading.value = false;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -416,7 +416,7 @@
|
|||
🔒 Some member information is visible to members only
|
||||
</p>
|
||||
<div class="flex gap-3 justify-center">
|
||||
<UButton to="/login" variant="outline"> Log In </UButton>
|
||||
<UButton @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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -427,6 +427,7 @@
|
|||
|
||||
<script setup>
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { openLoginModal } = useLoginModal();
|
||||
const { render: renderMarkdown } = useMarkdown();
|
||||
|
||||
// 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 { connectDB } from '../server/utils/mongoose.js'
|
||||
import dotenv from 'dotenv'
|
||||
import mongoose from "mongoose";
|
||||
import { connectDB } from "../server/utils/mongoose.js";
|
||||
import dotenv from "dotenv";
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config()
|
||||
dotenv.config();
|
||||
|
||||
// Import seed functions
|
||||
import { execSync } from 'child_process'
|
||||
import { execSync } from "child_process";
|
||||
|
||||
async function seedAll() {
|
||||
try {
|
||||
console.log('🌱 Starting database seeding...\n')
|
||||
|
||||
console.log("🌱 Starting database seeding...\n");
|
||||
|
||||
// Seed members
|
||||
console.log('👥 Seeding members...')
|
||||
execSync('node scripts/seed-members.js', { stdio: 'inherit' })
|
||||
|
||||
console.log('\n🎉 Seeding events...')
|
||||
execSync('node scripts/seed-events.js', { stdio: 'inherit' })
|
||||
|
||||
console.log('\n✅ All data seeded successfully!')
|
||||
console.log('\n📊 Database Summary:')
|
||||
|
||||
console.log("👥 Seeding members...");
|
||||
execSync("node scripts/seed-members.js", { stdio: "inherit" });
|
||||
|
||||
console.log("\n🎉 Seeding events...");
|
||||
execSync("node scripts/seed-events.js", { stdio: "inherit" });
|
||||
|
||||
console.log("\n📅 Seeding series events...");
|
||||
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
|
||||
await connectDB()
|
||||
|
||||
const Member = (await import('../server/models/member.js')).default
|
||||
const Event = (await import('../server/models/event.js')).default
|
||||
|
||||
const memberCount = await Member.countDocuments()
|
||||
const eventCount = await Event.countDocuments()
|
||||
|
||||
console.log(` Members: ${memberCount}`)
|
||||
console.log(` Events: ${eventCount}`)
|
||||
|
||||
process.exit(0)
|
||||
await connectDB();
|
||||
|
||||
const Member = (await import("../server/models/member.js")).default;
|
||||
const Event = (await import("../server/models/event.js")).default;
|
||||
|
||||
const memberCount = await Member.countDocuments();
|
||||
const eventCount = await Event.countDocuments();
|
||||
|
||||
console.log(` Members: ${memberCount}`);
|
||||
console.log(` Events: ${eventCount}`);
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('❌ Error seeding database:', error)
|
||||
process.exit(1)
|
||||
console.error("❌ Error seeding database:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
seedAll()
|
||||
seedAll();
|
||||
|
|
|
|||
|
|
@ -1,187 +1,220 @@
|
|||
import { connectDB } from '../server/utils/mongoose.js'
|
||||
import Event from '../server/models/event.js'
|
||||
import { connectDB } from "../server/utils/mongoose.js";
|
||||
import Event from "../server/models/event.js";
|
||||
|
||||
async function seedSeriesEvents() {
|
||||
try {
|
||||
await connectDB()
|
||||
console.log('Connected to database')
|
||||
await connectDB();
|
||||
console.log("Connected to database");
|
||||
|
||||
// Generate future dates relative to today
|
||||
const today = new Date();
|
||||
|
||||
// Workshop Series: "Cooperative Game Development Fundamentals"
|
||||
const workshopSeries = [
|
||||
{
|
||||
title: 'Cooperative Business Models in Game Development',
|
||||
slug: 'coop-business-models-workshop',
|
||||
tagline: 'Learn the foundations of cooperative business structures',
|
||||
description: 'An introductory workshop covering the basic principles and structures of worker cooperatives in the game development industry.',
|
||||
content: 'This workshop will cover the legal structures, governance models, and financial frameworks that make cooperative game studios successful.',
|
||||
startDate: new Date('2024-10-15T19:00:00.000Z'),
|
||||
endDate: new Date('2024-10-15T21:00:00.000Z'),
|
||||
eventType: 'workshop',
|
||||
location: '#workshop-fundamentals',
|
||||
title: "Cooperative Business Models in Game Development",
|
||||
slug: "coop-business-models-workshop",
|
||||
tagline: "Learn the foundations of cooperative business structures",
|
||||
description:
|
||||
"An introductory workshop covering the basic principles and structures of worker cooperatives in the game development industry.",
|
||||
content:
|
||||
"This workshop will cover the legal structures, governance models, and financial frameworks that make cooperative game studios successful.",
|
||||
startDate: new Date(
|
||||
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,
|
||||
membersOnly: false,
|
||||
registrationRequired: true,
|
||||
maxAttendees: 50,
|
||||
series: {
|
||||
id: 'coop-dev-fundamentals',
|
||||
title: 'Cooperative Game Development Fundamentals',
|
||||
description: 'A comprehensive workshop series covering the essentials of building and running cooperative game studios',
|
||||
type: 'workshop_series',
|
||||
id: "coop-dev-fundamentals",
|
||||
title: "Cooperative Game Development Fundamentals",
|
||||
description:
|
||||
"A comprehensive workshop series covering the essentials of building and running cooperative game studios",
|
||||
type: "workshop_series",
|
||||
position: 1,
|
||||
totalEvents: 4,
|
||||
isSeriesEvent: true
|
||||
isSeriesEvent: true,
|
||||
},
|
||||
createdBy: 'admin'
|
||||
createdBy: "admin",
|
||||
},
|
||||
{
|
||||
title: 'Democratic Decision Making in Creative Projects',
|
||||
slug: 'democratic-decision-making-workshop',
|
||||
tagline: 'Practical tools for collaborative project management',
|
||||
description: 'Learn how to implement democratic decision-making processes that work for creative teams and game development projects.',
|
||||
content: 'This workshop focuses on consensus building, conflict resolution, and collaborative project management techniques.',
|
||||
startDate: new Date('2024-10-22T19:00:00.000Z'),
|
||||
endDate: new Date('2024-10-22T21:00:00.000Z'),
|
||||
eventType: 'workshop',
|
||||
location: '#workshop-fundamentals',
|
||||
title: "Democratic Decision Making in Creative Projects",
|
||||
slug: "democratic-decision-making-workshop",
|
||||
tagline: "Practical tools for collaborative project management",
|
||||
description:
|
||||
"Learn how to implement democratic decision-making processes that work for creative teams and game development projects.",
|
||||
content:
|
||||
"This workshop focuses on consensus building, conflict resolution, and collaborative project management techniques.",
|
||||
startDate: new Date("2024-10-22T19:00:00.000Z"),
|
||||
endDate: new Date("2024-10-22T21:00:00.000Z"),
|
||||
eventType: "workshop",
|
||||
location: "#workshop-fundamentals",
|
||||
isOnline: true,
|
||||
membersOnly: false,
|
||||
registrationRequired: true,
|
||||
maxAttendees: 50,
|
||||
series: {
|
||||
id: 'coop-dev-fundamentals',
|
||||
title: 'Cooperative Game Development Fundamentals',
|
||||
description: 'A comprehensive workshop series covering the essentials of building and running cooperative game studios',
|
||||
type: 'workshop_series',
|
||||
id: "coop-dev-fundamentals",
|
||||
title: "Cooperative Game Development Fundamentals",
|
||||
description:
|
||||
"A comprehensive workshop series covering the essentials of building and running cooperative game studios",
|
||||
type: "workshop_series",
|
||||
position: 2,
|
||||
totalEvents: 4,
|
||||
isSeriesEvent: true
|
||||
isSeriesEvent: true,
|
||||
},
|
||||
createdBy: 'admin'
|
||||
createdBy: "admin",
|
||||
},
|
||||
{
|
||||
title: 'Funding and Financial Models for Co-ops',
|
||||
slug: 'coop-funding-workshop',
|
||||
tagline: 'Sustainable financing for cooperative studios',
|
||||
description: 'Explore funding options, revenue sharing models, and financial management strategies specific to cooperative game studios.',
|
||||
content: 'This workshop covers grant opportunities, crowdfunding strategies, and internal financial management for worker cooperatives.',
|
||||
startDate: new Date('2024-10-29T19:00:00.000Z'),
|
||||
endDate: new Date('2024-10-29T21:00:00.000Z'),
|
||||
eventType: 'workshop',
|
||||
location: '#workshop-fundamentals',
|
||||
title: "Funding and Financial Models for Co-ops",
|
||||
slug: "coop-funding-workshop",
|
||||
tagline: "Sustainable financing for cooperative studios",
|
||||
description:
|
||||
"Explore funding options, revenue sharing models, and financial management strategies specific to cooperative game studios.",
|
||||
content:
|
||||
"This workshop covers grant opportunities, crowdfunding strategies, and internal financial management for worker cooperatives.",
|
||||
startDate: new Date("2024-10-29T19:00:00.000Z"),
|
||||
endDate: new Date("2024-10-29T21:00:00.000Z"),
|
||||
eventType: "workshop",
|
||||
location: "#workshop-fundamentals",
|
||||
isOnline: true,
|
||||
membersOnly: false,
|
||||
registrationRequired: true,
|
||||
maxAttendees: 50,
|
||||
series: {
|
||||
id: 'coop-dev-fundamentals',
|
||||
title: 'Cooperative Game Development Fundamentals',
|
||||
description: 'A comprehensive workshop series covering the essentials of building and running cooperative game studios',
|
||||
type: 'workshop_series',
|
||||
id: "coop-dev-fundamentals",
|
||||
title: "Cooperative Game Development Fundamentals",
|
||||
description:
|
||||
"A comprehensive workshop series covering the essentials of building and running cooperative game studios",
|
||||
type: "workshop_series",
|
||||
position: 3,
|
||||
totalEvents: 4,
|
||||
isSeriesEvent: true
|
||||
isSeriesEvent: true,
|
||||
},
|
||||
createdBy: 'admin'
|
||||
createdBy: "admin",
|
||||
},
|
||||
{
|
||||
title: 'Building Your Cooperative Studio',
|
||||
slug: 'building-coop-studio-workshop',
|
||||
tagline: 'From concept to reality: launching your co-op',
|
||||
description: 'A practical guide to forming a cooperative game studio, covering legal formation, member recruitment, and launch strategies.',
|
||||
content: 'This final workshop in the series provides a step-by-step guide to actually forming and launching a cooperative game studio.',
|
||||
startDate: new Date('2024-11-05T19:00:00.000Z'),
|
||||
endDate: new Date('2024-11-05T21:00:00.000Z'),
|
||||
eventType: 'workshop',
|
||||
location: '#workshop-fundamentals',
|
||||
title: "Building Your Cooperative Studio",
|
||||
slug: "building-coop-studio-workshop",
|
||||
tagline: "From concept to reality: launching your co-op",
|
||||
description:
|
||||
"A practical guide to forming a cooperative game studio, covering legal formation, member recruitment, and launch strategies.",
|
||||
content:
|
||||
"This final workshop in the series provides a step-by-step guide to actually forming and launching a cooperative game studio.",
|
||||
startDate: new Date("2024-11-05T19:00:00.000Z"),
|
||||
endDate: new Date("2024-11-05T21:00:00.000Z"),
|
||||
eventType: "workshop",
|
||||
location: "#workshop-fundamentals",
|
||||
isOnline: true,
|
||||
membersOnly: false,
|
||||
registrationRequired: true,
|
||||
maxAttendees: 50,
|
||||
series: {
|
||||
id: 'coop-dev-fundamentals',
|
||||
title: 'Cooperative Game Development Fundamentals',
|
||||
description: 'A comprehensive workshop series covering the essentials of building and running cooperative game studios',
|
||||
type: 'workshop_series',
|
||||
id: "coop-dev-fundamentals",
|
||||
title: "Cooperative Game Development Fundamentals",
|
||||
description:
|
||||
"A comprehensive workshop series covering the essentials of building and running cooperative game studios",
|
||||
type: "workshop_series",
|
||||
position: 4,
|
||||
totalEvents: 4,
|
||||
isSeriesEvent: true
|
||||
isSeriesEvent: true,
|
||||
},
|
||||
createdBy: 'admin'
|
||||
}
|
||||
]
|
||||
createdBy: "admin",
|
||||
},
|
||||
];
|
||||
|
||||
// Monthly Community Meetup Series
|
||||
const meetupSeries = [
|
||||
{
|
||||
title: 'October Community Meetup',
|
||||
slug: 'october-community-meetup',
|
||||
tagline: 'Monthly gathering for cooperative game developers',
|
||||
description: 'Join fellow cooperative game developers for informal networking, project sharing, and community building.',
|
||||
content: 'Our monthly community meetup provides a relaxed environment to share your projects, get feedback, and connect with other developers interested in cooperative models.',
|
||||
startDate: new Date('2024-10-12T18:00:00.000Z'),
|
||||
endDate: new Date('2024-10-12T20:00:00.000Z'),
|
||||
eventType: 'community',
|
||||
location: '#community-meetup',
|
||||
title: "October Community Meetup",
|
||||
slug: "october-community-meetup",
|
||||
tagline: "Monthly gathering for cooperative game developers",
|
||||
description:
|
||||
"Join fellow cooperative game developers for informal networking, project sharing, and community building.",
|
||||
content:
|
||||
"Our monthly community meetup provides a relaxed environment to share your projects, get feedback, and connect with other developers interested in cooperative models.",
|
||||
startDate: new Date("2024-10-12T18:00:00.000Z"),
|
||||
endDate: new Date("2024-10-12T20:00:00.000Z"),
|
||||
eventType: "community",
|
||||
location: "#community-meetup",
|
||||
isOnline: true,
|
||||
membersOnly: false,
|
||||
registrationRequired: false,
|
||||
series: {
|
||||
id: 'monthly-meetups',
|
||||
title: 'Monthly Community Meetups',
|
||||
description: 'Regular monthly gatherings for the cooperative game development community',
|
||||
type: 'recurring_meetup',
|
||||
id: "monthly-meetups",
|
||||
title: "Monthly Community Meetups",
|
||||
description:
|
||||
"Regular monthly gatherings for the cooperative game development community",
|
||||
type: "recurring_meetup",
|
||||
position: 1,
|
||||
totalEvents: 12,
|
||||
isSeriesEvent: true
|
||||
isSeriesEvent: true,
|
||||
},
|
||||
createdBy: 'admin'
|
||||
createdBy: "admin",
|
||||
},
|
||||
{
|
||||
title: 'November Community Meetup',
|
||||
slug: 'november-community-meetup',
|
||||
tagline: 'Monthly gathering for cooperative game developers',
|
||||
description: 'Join fellow cooperative game developers for informal networking, project sharing, and community building.',
|
||||
content: 'Our monthly community meetup provides a relaxed environment to share your projects, get feedback, and connect with other developers interested in cooperative models.',
|
||||
startDate: new Date('2024-11-09T18:00:00.000Z'),
|
||||
endDate: new Date('2024-11-09T20:00:00.000Z'),
|
||||
eventType: 'community',
|
||||
location: '#community-meetup',
|
||||
title: "November Community Meetup",
|
||||
slug: "november-community-meetup",
|
||||
tagline: "Monthly gathering for cooperative game developers",
|
||||
description:
|
||||
"Join fellow cooperative game developers for informal networking, project sharing, and community building.",
|
||||
content:
|
||||
"Our monthly community meetup provides a relaxed environment to share your projects, get feedback, and connect with other developers interested in cooperative models.",
|
||||
startDate: new Date("2024-11-09T18:00:00.000Z"),
|
||||
endDate: new Date("2024-11-09T20:00:00.000Z"),
|
||||
eventType: "community",
|
||||
location: "#community-meetup",
|
||||
isOnline: true,
|
||||
membersOnly: false,
|
||||
registrationRequired: false,
|
||||
series: {
|
||||
id: 'monthly-meetups',
|
||||
title: 'Monthly Community Meetups',
|
||||
description: 'Regular monthly gatherings for the cooperative game development community',
|
||||
type: 'recurring_meetup',
|
||||
id: "monthly-meetups",
|
||||
title: "Monthly Community Meetups",
|
||||
description:
|
||||
"Regular monthly gatherings for the cooperative game development community",
|
||||
type: "recurring_meetup",
|
||||
position: 2,
|
||||
totalEvents: 12,
|
||||
isSeriesEvent: true
|
||||
isSeriesEvent: true,
|
||||
},
|
||||
createdBy: 'admin'
|
||||
}
|
||||
]
|
||||
createdBy: "admin",
|
||||
},
|
||||
];
|
||||
|
||||
// Insert all series events
|
||||
const allSeriesEvents = [...workshopSeries, ...meetupSeries]
|
||||
|
||||
const allSeriesEvents = [...workshopSeries, ...meetupSeries];
|
||||
|
||||
for (const eventData of allSeriesEvents) {
|
||||
const existingEvent = await Event.findOne({ slug: eventData.slug })
|
||||
const existingEvent = await Event.findOne({ slug: eventData.slug });
|
||||
if (!existingEvent) {
|
||||
const event = new Event(eventData)
|
||||
await event.save()
|
||||
console.log(`Created series event: ${event.title}`)
|
||||
const event = new Event(eventData);
|
||||
await event.save();
|
||||
console.log(`Created series event: ${event.title}`);
|
||||
} else {
|
||||
console.log(`Series event already exists: ${eventData.title}`)
|
||||
console.log(`Series event already exists: ${eventData.title}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Series events seeding completed!')
|
||||
process.exit(0)
|
||||
console.log("Series events seeding completed!");
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Error seeding series events:', error)
|
||||
process.exit(1)
|
||||
console.error("Error seeding series events:", error);
|
||||
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 { sendEventCancellationEmail } from "../../../utils/resend.js";
|
||||
import {
|
||||
sendEventCancellationEmail,
|
||||
sendWaitlistNotificationEmail,
|
||||
} from "../../../utils/resend.js";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const id = getRouterParam(event, "id");
|
||||
|
|
@ -71,6 +74,39 @@ export default defineEventHandler(async (event) => {
|
|||
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 {
|
||||
success: true,
|
||||
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 { connectDB } from '../../utils/mongoose.js'
|
||||
import Event from "../../models/event.js";
|
||||
import { connectDB } from "../../utils/mongoose.js";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
// Ensure database connection
|
||||
await connectDB()
|
||||
await connectDB();
|
||||
// Get query parameters for filtering
|
||||
const query = getQuery(event)
|
||||
const filter = {}
|
||||
|
||||
const query = getQuery(event);
|
||||
const filter = {};
|
||||
|
||||
// Only show visible events on public calendar (unless specifically requested)
|
||||
if (query.includeHidden !== 'true') {
|
||||
filter.isVisible = true
|
||||
if (query.includeHidden !== "true") {
|
||||
filter.isVisible = true;
|
||||
}
|
||||
|
||||
|
||||
// Filter for upcoming events only if requested
|
||||
if (query.upcoming === 'true') {
|
||||
filter.startDate = { $gte: new Date() }
|
||||
if (query.upcoming === "true") {
|
||||
filter.startDate = { $gte: new Date() };
|
||||
}
|
||||
|
||||
|
||||
// Filter by event type if provided
|
||||
if (query.eventType) {
|
||||
filter.eventType = query.eventType
|
||||
filter.eventType = query.eventType;
|
||||
}
|
||||
|
||||
|
||||
// Filter for members-only events
|
||||
if (query.membersOnly !== undefined) {
|
||||
filter.membersOnly = query.membersOnly === 'true'
|
||||
filter.membersOnly = query.membersOnly === "true";
|
||||
}
|
||||
|
||||
|
||||
// Fetch events from database
|
||||
const events = await Event.find(filter)
|
||||
.sort({ startDate: 1 })
|
||||
.select('-registrations') // Don't expose registration details in list view
|
||||
.lean()
|
||||
|
||||
.select("-registrations") // Don't expose registration details in list view
|
||||
.lean();
|
||||
|
||||
// Add computed fields
|
||||
const eventsWithMeta = events.map(event => ({
|
||||
const eventsWithMeta = events.map((event) => ({
|
||||
...event,
|
||||
id: event._id.toString(),
|
||||
registeredCount: event.registrations?.length || 0
|
||||
}))
|
||||
|
||||
return eventsWithMeta
|
||||
registeredCount: event.registrations?.length || 0,
|
||||
}));
|
||||
|
||||
return eventsWithMeta;
|
||||
} catch (error) {
|
||||
console.error('Error fetching events:', error)
|
||||
console.error("Error fetching events:", error);
|
||||
throw createError({
|
||||
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
|
||||
*/
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue