diff --git a/.gitignore b/.gitignore index 4a7f73a..8f7a3dd 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,5 @@ logs .env .env.* !.env.example +/*.md +scripts/*.js diff --git a/app/app.vue b/app/app.vue index 5acf3c1..84d4ee0 100644 --- a/app/app.vue +++ b/app/app.vue @@ -3,5 +3,6 @@ + diff --git a/app/components/LoginModal.vue b/app/components/LoginModal.vue new file mode 100644 index 0000000..064c7ed --- /dev/null +++ b/app/components/LoginModal.vue @@ -0,0 +1,201 @@ + + + diff --git a/app/components/MemberStatusBanner.vue b/app/components/MemberStatusBanner.vue new file mode 100644 index 0000000..ea235cd --- /dev/null +++ b/app/components/MemberStatusBanner.vue @@ -0,0 +1,126 @@ + + + diff --git a/app/components/MemberStatusIndicator.vue b/app/components/MemberStatusIndicator.vue new file mode 100644 index 0000000..246725c --- /dev/null +++ b/app/components/MemberStatusIndicator.vue @@ -0,0 +1,13 @@ + + + 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 @@ + + + diff --git a/app/pages/events/[id].vue b/app/pages/events/[id].vue index 36ff011..b894bec 100644 --- a/app/pages/events/[id].vue +++ b/app/pages/events/[id].vue @@ -79,7 +79,7 @@
-
+

Date

@@ -100,6 +100,20 @@ {{ event.location }}

+ +
+

Calendar

+ + Add to Calendar + +
@@ -270,28 +284,64 @@
- -
-

- You are logged in, {{ memberData.name }}. -

- +
+
- {{ isRegistering ? "Registering..." : "Register Now" }} - + +

+ {{ statusConfig.label }} +

+

+ {{ getRSVPMessage }} +

+ + {{ + isProcessingPayment ? "Processing..." : "Complete Payment" + }} + + + + Reactivate Membership + + + + + Contact Support + + +
- +
+ +
+

+ You are logged in, {{ memberData.name }}. +

+ + {{ isRegistering ? "Registering..." : "Register Now" }} + +
+

@@ -403,6 +472,64 @@

+ + +
+ +
+
+

+ You're on the waitlist! +

+

+ Position #{{ waitlistPosition }} - We'll email you if a spot opens up +

+
+ + Leave Waitlist + +
+ + +
+
+

+ This event is full +

+

+ Join the waitlist and we'll notify you if a spot opens up +

+
+ +
+
+ +
+ + {{ isJoiningWaitlist ? "Joining..." : "Join Waitlist" }} + +
+
+
@@ -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++; diff --git a/app/pages/events/index.vue b/app/pages/events/index.vue index 2330ec7..239a1d9 100644 --- a/app/pages/events/index.vue +++ b/app/pages/events/index.vue @@ -13,13 +13,115 @@