Many an update!

This commit is contained in:
Jennie Robinson Faber 2025-12-01 15:26:42 +00:00
parent 85195d6c7a
commit d588c49946
35 changed files with 3528 additions and 1142 deletions

View 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,
};
};

View 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,
};
};

View file

@ -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...");

View 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,
}
}

View 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,
}
}

View 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,
}
}