+
+
+
diff --git a/app/composables/useCalendarSearch.js b/app/composables/useCalendarSearch.js
new file mode 100644
index 0000000..0fd3d88
--- /dev/null
+++ b/app/composables/useCalendarSearch.js
@@ -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,
+ };
+};
diff --git a/app/composables/useEventDateUtils.js b/app/composables/useEventDateUtils.js
new file mode 100644
index 0000000..ab536b4
--- /dev/null
+++ b/app/composables/useEventDateUtils.js
@@ -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,
+ };
+};
diff --git a/app/composables/useHelcimPay.js b/app/composables/useHelcimPay.js
index b7926cc..9ca1330 100644
--- a/app/composables/useHelcimPay.js
+++ b/app/composables/useHelcimPay.js
@@ -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...");
diff --git a/app/composables/useLoginModal.js b/app/composables/useLoginModal.js
new file mode 100644
index 0000000..e4a637c
--- /dev/null
+++ b/app/composables/useLoginModal.js
@@ -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,
+ }
+}
diff --git a/app/composables/useMemberPayment.js b/app/composables/useMemberPayment.js
new file mode 100644
index 0000000..6b49ecd
--- /dev/null
+++ b/app/composables/useMemberPayment.js
@@ -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,
+ }
+}
diff --git a/app/composables/useMemberStatus.js b/app/composables/useMemberStatus.js
new file mode 100644
index 0000000..44f37e9
--- /dev/null
+++ b/app/composables/useMemberStatus.js
@@ -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,
+ }
+}
diff --git a/app/layouts/admin.vue b/app/layouts/admin.vue
index fa8e7a5..92a57e7 100644
--- a/app/layouts/admin.vue
+++ b/app/layouts/admin.vue
@@ -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);
}
diff --git a/app/middleware/auth.js b/app/middleware/auth.js
index a29566b..bc92f62 100644
--- a/app/middleware/auth.js
+++ b/app/middleware/auth.js
@@ -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)
})
\ No newline at end of file
diff --git a/app/pages/about.vue b/app/pages/about.vue
index 7dbb0b9..6af3995 100644
--- a/app/pages/about.vue
+++ b/app/pages/about.vue
@@ -2,206 +2,63 @@
-
+
-
-
- How Ghost Guild Works
-
-
-
-
- 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.
-
-
-
-
- The entire knowledge commons, all events, and full community
- participation on our private Slack
-
-
One member, one vote in all decisions
-
Pay what you can ($0-50+/month)
-
Contribute your skills, time, and knowledge
-
-
+
+
+
+ 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.
+
+
+ something here about the work to make Slack integration smooth and
+ safe; more about purpose??
+
+
+ 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.
+
-
+
-
-
- Find your circle
+
+
+ Membership Circles
-
- Circles help us provide relevant guidance and connect you with
- others at similar stages. Choose based on where you are now!
+
+ Learn about our three membership circles and find where you fit.
-
-
-
-
-
- Community Circle
-
-
-
-
- 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.
-
-
-
- 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 β’ anyone who's
- co-op-curious!
-
-
-
- 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!
-
-
-
-
-
-
-
- Founder Circle
-
-
-
-
- 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.
-
-
-
- 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.
-
-
-
- We have two paths through this circle that we will be
- launching soon:
-
-
-
-
- Peer Accelerator Prep Track (coming soon) β
- Structured preparation if you're planning to apply for the
- PA program
-
-
- Indie Track (coming soon) β Flexible, self-paced
- support for teams building at their own pace
-
-
-
-
- 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.
-
-
-
-
-
-
-
- Practitioner Circle
-
-
-
-
- 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
- you've learned.
-
-
-
- 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
-
-
-
- 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.
-
-
-
-
-
-
-
-
-
-
-
-
-
- Important Notes
-
-
-
-
- Movement between circles is fluid. As you move
- along in your journey, you can shift circles anytime. Just let us
- know.
-
-
-
- Your contribution is separate from your circle.
- Whether you contribute $0 or $50+/month, you get full access to
- everything. Choose based on your financial capacity, not your
- circle.
-
-
-
- Not sure which circle? Start with Community - you
- can always move. Or email us and we'll chat about what makes sense
- for you.
-
-
+
+ Explore Membership Circles
+
diff --git a/app/pages/about/circles.vue b/app/pages/about/circles.vue
new file mode 100644
index 0000000..1fc7786
--- /dev/null
+++ b/app/pages/about/circles.vue
@@ -0,0 +1,213 @@
+
+
+
+
+
+
+
+
+
+
+ How membership works
+
+
+
+
+ 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.
+
+
+
+
+ The entire knowledge commons, all events, and full community
+ participation on our private Slack
+
+
One member, one vote in all decisions
+
Pay what you can ($0-50+/month)
+
Contribute your skills, time, and knowledge
+
+
+
+
+
+
+
+
+
+
+
+ Find your circle
+
+
+ Circles help us provide relevant guidance and connect you with
+ others at similar stages. Choose based on where you are now!
+
+
+
+
+
+
+ Community Circle
+
+
+
+
+ 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.
+
+
+
+ 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 β’ anyone who's
+ co-op-curious!
+
+
+
+ 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!
+
+
+
+
+
+
+
+ Founder Circle
+
+
+
+
+ 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.
+
+
+
+ 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.
+
+
+
+ We have two paths through this circle that we will be
+ launching soon:
+
+
+
+
+ Peer Accelerator Prep Track (coming soon) β
+ Structured preparation if you're planning to apply for the
+ PA program
+
+
+ Indie Track (coming soon) β Flexible, self-paced
+ support for teams building at their own pace
+
+
+
+
+ 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.
+
+
+
+
+
+
+
+ Practitioner Circle
+
+
+
+
+ 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
+ you've learned.
+
+
+
+ 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
+
+
+
+ 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.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Important Notes
+
+
+
+
+ Movement between circles is fluid. As you move
+ along in your journey, you can shift circles anytime. Just let us
+ know.
+
+
+
+ Your contribution is separate from your circle.
+ Whether you contribute $0 or $50+/month, you get full access to
+ everything. Choose based on your financial capacity, not your
+ circle.
+
+
+
+ Not sure which circle? Start with Community - you
+ can always move. Or email us and we'll chat about what makes sense
+ for you.
+
- 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.
- 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.
@@ -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)}`;
- }
-};
+
+
+
+
A Spot Just Opened Up! π
+
+
+
+
Hi ${waitlistEntry.name},
+
+
Great news! A spot has become available for ${eventData.title}, and you're on the waitlist.
+
+
+
+ β° Act fast! Spots are filled on a first-come, first-served basis.
+