Many an update!
This commit is contained in:
parent
85195d6c7a
commit
d588c49946
35 changed files with 3528 additions and 1142 deletions
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,
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue