Implement multi-step registration process: Add step indicators, error handling, and payment processing for membership registration. Enhance form validation and user feedback with success and error messages. Refactor state management for improved clarity and maintainability.
This commit is contained in:
parent
a88aa62198
commit
2ca290d6e0
22 changed files with 1994 additions and 213 deletions
48
app/composables/useAuth.js
Normal file
48
app/composables/useAuth.js
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
export const useAuth = () => {
|
||||
const authCookie = useCookie('auth-token')
|
||||
const memberData = useState('auth.member', () => null)
|
||||
const isAuthenticated = computed(() => !!authCookie.value)
|
||||
const isMember = computed(() => !!memberData.value)
|
||||
|
||||
const checkMemberStatus = async () => {
|
||||
if (!authCookie.value) {
|
||||
memberData.value = null
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await $fetch('/api/auth/member', {
|
||||
headers: {
|
||||
'Cookie': `auth-token=${authCookie.value}`
|
||||
}
|
||||
})
|
||||
memberData.value = response
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch member status:', error)
|
||||
memberData.value = null
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
await $fetch('/api/auth/logout', {
|
||||
method: 'POST'
|
||||
})
|
||||
authCookie.value = null
|
||||
memberData.value = null
|
||||
await navigateTo('/')
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isAuthenticated: readonly(isAuthenticated),
|
||||
isMember: readonly(isMember),
|
||||
memberData: readonly(memberData),
|
||||
checkMemberStatus,
|
||||
logout
|
||||
}
|
||||
}
|
||||
90
app/composables/useHelcim.js
Normal file
90
app/composables/useHelcim.js
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
// Helcim API integration composable
|
||||
export const useHelcim = () => {
|
||||
const config = useRuntimeConfig()
|
||||
const helcimToken = config.public.helcimToken
|
||||
|
||||
// Base URL for Helcim API
|
||||
const HELCIM_API_BASE = 'https://api.helcim.com/v2'
|
||||
|
||||
// Helper function to make API requests
|
||||
const makeHelcimRequest = async (endpoint, method = 'GET', body = null) => {
|
||||
try {
|
||||
const response = await $fetch(`${HELCIM_API_BASE}${endpoint}`, {
|
||||
method,
|
||||
headers: {
|
||||
'accept': 'application/json',
|
||||
'content-type': 'application/json',
|
||||
'api-token': helcimToken
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined
|
||||
})
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('Helcim API error:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Create a customer
|
||||
const createCustomer = async (customerData) => {
|
||||
return await makeHelcimRequest('/customers', 'POST', {
|
||||
customerType: 'PERSON',
|
||||
contactName: customerData.name,
|
||||
email: customerData.email,
|
||||
billingAddress: customerData.billingAddress || {}
|
||||
})
|
||||
}
|
||||
|
||||
// Create a subscription
|
||||
const createSubscription = async (customerId, planId, cardToken) => {
|
||||
return await makeHelcimRequest('/recurring/subscriptions', 'POST', {
|
||||
customerId,
|
||||
planId,
|
||||
cardToken,
|
||||
startDate: new Date().toISOString().split('T')[0] // Today's date
|
||||
})
|
||||
}
|
||||
|
||||
// Get customer details
|
||||
const getCustomer = async (customerId) => {
|
||||
return await makeHelcimRequest(`/customers/${customerId}`)
|
||||
}
|
||||
|
||||
// Get subscription details
|
||||
const getSubscription = async (subscriptionId) => {
|
||||
return await makeHelcimRequest(`/recurring/subscriptions/${subscriptionId}`)
|
||||
}
|
||||
|
||||
// Update subscription
|
||||
const updateSubscription = async (subscriptionId, updates) => {
|
||||
return await makeHelcimRequest(`/recurring/subscriptions/${subscriptionId}`, 'PATCH', updates)
|
||||
}
|
||||
|
||||
// Cancel subscription
|
||||
const cancelSubscription = async (subscriptionId) => {
|
||||
return await makeHelcimRequest(`/recurring/subscriptions/${subscriptionId}`, 'DELETE')
|
||||
}
|
||||
|
||||
// Get payment plans
|
||||
const getPaymentPlans = async () => {
|
||||
return await makeHelcimRequest('/recurring/plans')
|
||||
}
|
||||
|
||||
// Verify card token (for testing)
|
||||
const verifyCardToken = async (cardToken) => {
|
||||
return await makeHelcimRequest('/cards/verify', 'POST', {
|
||||
cardToken
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
createCustomer,
|
||||
createSubscription,
|
||||
getCustomer,
|
||||
getSubscription,
|
||||
updateSubscription,
|
||||
cancelSubscription,
|
||||
getPaymentPlans,
|
||||
verifyCardToken
|
||||
}
|
||||
}
|
||||
158
app/composables/useHelcimPay.js
Normal file
158
app/composables/useHelcimPay.js
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
// HelcimPay.js integration composable
|
||||
export const useHelcimPay = () => {
|
||||
let checkoutToken = null
|
||||
let secretToken = null
|
||||
|
||||
// Initialize HelcimPay.js session
|
||||
const initializeHelcimPay = async (customerId, customerCode, amount = 0) => {
|
||||
try {
|
||||
const response = await $fetch('/api/helcim/initialize-payment', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
customerId,
|
||||
customerCode,
|
||||
amount
|
||||
}
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
checkoutToken = response.checkoutToken
|
||||
secretToken = response.secretToken
|
||||
return true
|
||||
}
|
||||
|
||||
throw new Error('Failed to initialize payment session')
|
||||
} catch (error) {
|
||||
console.error('Payment initialization error:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Show payment modal
|
||||
const showPaymentModal = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!checkoutToken) {
|
||||
reject(new Error('Payment not initialized. Call initializeHelcimPay first.'))
|
||||
return
|
||||
}
|
||||
|
||||
// Load HelcimPay.js modal script
|
||||
if (!window.appendHelcimPayIframe) {
|
||||
console.log('HelcimPay script not loaded, loading now...')
|
||||
const script = document.createElement('script')
|
||||
script.src = 'https://secure.helcim.app/helcim-pay/services/start.js'
|
||||
script.async = true
|
||||
script.onload = () => {
|
||||
console.log('HelcimPay script loaded successfully!')
|
||||
console.log('Available functions:', Object.keys(window).filter(key => key.includes('Helcim') || key.includes('helcim')))
|
||||
console.log('appendHelcimPayIframe available:', typeof window.appendHelcimPayIframe)
|
||||
openModal(resolve, reject)
|
||||
}
|
||||
script.onerror = () => {
|
||||
reject(new Error('Failed to load HelcimPay.js'))
|
||||
}
|
||||
document.head.appendChild(script)
|
||||
} else {
|
||||
console.log('HelcimPay script already loaded, calling openModal')
|
||||
openModal(resolve, reject)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Open the payment modal
|
||||
const openModal = (resolve, reject) => {
|
||||
try {
|
||||
console.log('Trying to open modal with checkoutToken:', checkoutToken)
|
||||
|
||||
if (typeof window.appendHelcimPayIframe === 'function') {
|
||||
// Set up event listener for HelcimPay.js responses
|
||||
const helcimPayJsIdentifierKey = 'helcim-pay-js-' + checkoutToken
|
||||
|
||||
const handleHelcimPayEvent = (event) => {
|
||||
console.log('Received window message:', event.data)
|
||||
|
||||
if (event.data.eventName === helcimPayJsIdentifierKey) {
|
||||
console.log('HelcimPay event received:', event.data)
|
||||
|
||||
// Remove event listener to prevent multiple responses
|
||||
window.removeEventListener('message', handleHelcimPayEvent)
|
||||
|
||||
if (event.data.eventStatus === 'SUCCESS') {
|
||||
console.log('Payment success:', event.data.eventMessage)
|
||||
|
||||
// Parse the JSON string eventMessage
|
||||
let paymentData
|
||||
try {
|
||||
paymentData = JSON.parse(event.data.eventMessage)
|
||||
console.log('Parsed payment data:', paymentData)
|
||||
} catch (parseError) {
|
||||
console.error('Failed to parse eventMessage:', parseError)
|
||||
reject(new Error('Invalid payment response format'))
|
||||
return
|
||||
}
|
||||
|
||||
// Extract transaction details from nested data structure
|
||||
const transactionData = paymentData.data?.data || {}
|
||||
console.log('Transaction data:', transactionData)
|
||||
|
||||
resolve({
|
||||
success: true,
|
||||
transactionId: transactionData.transactionId,
|
||||
cardToken: transactionData.cardToken,
|
||||
cardLast4: transactionData.cardNumber ? transactionData.cardNumber.slice(-4) : undefined,
|
||||
cardType: transactionData.cardType || 'unknown'
|
||||
})
|
||||
} else if (event.data.eventStatus === 'ABORTED') {
|
||||
console.log('Payment aborted:', event.data.eventMessage)
|
||||
reject(new Error(event.data.eventMessage || 'Payment failed'))
|
||||
} else if (event.data.eventStatus === 'HIDE') {
|
||||
console.log('Modal closed without completion')
|
||||
reject(new Error('Payment cancelled by user'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add event listener
|
||||
window.addEventListener('message', handleHelcimPayEvent)
|
||||
|
||||
// Open the HelcimPay iframe modal
|
||||
console.log('Calling appendHelcimPayIframe with token:', checkoutToken)
|
||||
window.appendHelcimPayIframe(checkoutToken, true)
|
||||
console.log('appendHelcimPayIframe called, waiting for window messages...')
|
||||
|
||||
// Add timeout to clean up if no response
|
||||
setTimeout(() => {
|
||||
console.log('60 seconds passed, cleaning up event listener...')
|
||||
window.removeEventListener('message', handleHelcimPayEvent)
|
||||
reject(new Error('Payment timeout - no response received'))
|
||||
}, 60000)
|
||||
} else {
|
||||
reject(new Error('appendHelcimPayIframe function not available'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error opening modal:', error)
|
||||
reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
// Process payment verification
|
||||
const verifyPayment = async () => {
|
||||
try {
|
||||
return await showPaymentModal()
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup tokens
|
||||
const cleanup = () => {
|
||||
checkoutToken = null
|
||||
secretToken = null
|
||||
}
|
||||
|
||||
return {
|
||||
initializeHelcimPay,
|
||||
verifyPayment,
|
||||
cleanup
|
||||
}
|
||||
}
|
||||
|
|
@ -390,40 +390,6 @@
|
|||
</div>
|
||||
|
||||
<div v-if="selectedSeriesId || eventForm.series.id" class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Series ID <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="eventForm.series.id"
|
||||
type="text"
|
||||
placeholder="e.g., coop-dev-fundamentals"
|
||||
required
|
||||
:readonly="selectedSeriesId"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
:class="{ 'bg-gray-100': selectedSeriesId }"
|
||||
@input="!selectedSeriesId && checkExistingSeries()"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
{{ selectedSeriesId ? 'From selected series' : 'Unique identifier to group related events (use lowercase with dashes)' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Position in Series <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model.number="eventForm.series.position"
|
||||
type="number"
|
||||
min="1"
|
||||
required
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">Order within the series (1, 2, 3, etc.)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
|
|
@ -457,37 +423,6 @@
|
|||
<p class="text-xs text-gray-500 mt-1">{{ selectedSeriesId ? 'From selected series' : 'Describe what the series covers and its goals' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Series Type</label>
|
||||
<select
|
||||
v-model="eventForm.series.type"
|
||||
:disabled="selectedSeriesId"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
:class="{ 'bg-gray-100': selectedSeriesId }"
|
||||
>
|
||||
<option value="workshop_series">Workshop Series</option>
|
||||
<option value="recurring_meetup">Recurring Meetup</option>
|
||||
<option value="multi_day">Multi-Day Event</option>
|
||||
<option value="course">Course</option>
|
||||
<option value="tournament">Tournament</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Total Events Planned</label>
|
||||
<input
|
||||
v-model.number="eventForm.series.totalEvents"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="e.g., 4"
|
||||
:readonly="selectedSeriesId"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
:class="{ 'bg-gray-100': selectedSeriesId }"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">{{ selectedSeriesId ? 'From selected series' : 'How many events will be in this series?' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedSeriesId" class="p-3 bg-blue-50 rounded-lg">
|
||||
<p class="text-sm text-blue-700">
|
||||
|
|
@ -617,8 +552,6 @@ const editingEvent = ref(null)
|
|||
const showSuccessMessage = ref(false)
|
||||
const formErrors = ref([])
|
||||
const fieldErrors = ref({})
|
||||
const seriesExists = ref(false)
|
||||
const existingSeries = ref(null)
|
||||
const selectedSeriesId = ref('')
|
||||
const availableSeries = ref([])
|
||||
|
||||
|
|
@ -655,10 +588,7 @@ const eventForm = reactive({
|
|||
isSeriesEvent: false,
|
||||
id: '',
|
||||
title: '',
|
||||
description: '',
|
||||
type: 'workshop_series',
|
||||
position: 1,
|
||||
totalEvents: null
|
||||
description: ''
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -680,18 +610,12 @@ const onSeriesSelect = () => {
|
|||
eventForm.series.id = series.id
|
||||
eventForm.series.title = series.title
|
||||
eventForm.series.description = series.description
|
||||
eventForm.series.type = series.type
|
||||
eventForm.series.totalEvents = series.totalEvents
|
||||
eventForm.series.position = (series.eventCount || 0) + 1
|
||||
}
|
||||
} else {
|
||||
// Reset series form when no series is selected
|
||||
eventForm.series.id = ''
|
||||
eventForm.series.title = ''
|
||||
eventForm.series.description = ''
|
||||
eventForm.series.type = 'workshop_series'
|
||||
eventForm.series.position = 1
|
||||
eventForm.series.totalEvents = null
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -736,10 +660,7 @@ if (route.query.edit) {
|
|||
isSeriesEvent: false,
|
||||
id: '',
|
||||
title: '',
|
||||
description: '',
|
||||
type: 'workshop_series',
|
||||
position: 1,
|
||||
totalEvents: null
|
||||
description: ''
|
||||
}
|
||||
})
|
||||
// Handle early bird deadline formatting
|
||||
|
|
@ -851,62 +772,6 @@ const validateForm = () => {
|
|||
return formErrors.value.length === 0
|
||||
}
|
||||
|
||||
// Check if a series with this ID already exists
|
||||
const checkExistingSeries = async () => {
|
||||
if (!eventForm.series.id || selectedSeriesId.value) {
|
||||
seriesExists.value = false
|
||||
existingSeries.value = null
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// First check in standalone series
|
||||
const standaloneResponse = await $fetch(`/api/admin/series`)
|
||||
const existingStandalone = standaloneResponse.find(s => s.id === eventForm.series.id)
|
||||
|
||||
if (existingStandalone) {
|
||||
seriesExists.value = true
|
||||
existingSeries.value = existingStandalone
|
||||
// Auto-fill series details
|
||||
if (!eventForm.series.title || eventForm.series.title === '') {
|
||||
eventForm.series.title = existingStandalone.title
|
||||
}
|
||||
if (!eventForm.series.description || eventForm.series.description === '') {
|
||||
eventForm.series.description = existingStandalone.description
|
||||
}
|
||||
if (!eventForm.series.type || eventForm.series.type === 'workshop_series') {
|
||||
eventForm.series.type = existingStandalone.type
|
||||
}
|
||||
if (!eventForm.series.totalEvents || eventForm.series.totalEvents === null) {
|
||||
eventForm.series.totalEvents = existingStandalone.totalEvents
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback to legacy series check (events with series data)
|
||||
const legacyResponse = await $fetch(`/api/series/${eventForm.series.id}`)
|
||||
if (legacyResponse) {
|
||||
seriesExists.value = true
|
||||
existingSeries.value = legacyResponse
|
||||
if (!eventForm.series.title || eventForm.series.title === '') {
|
||||
eventForm.series.title = legacyResponse.title
|
||||
}
|
||||
if (!eventForm.series.description || eventForm.series.description === '') {
|
||||
eventForm.series.description = legacyResponse.description
|
||||
}
|
||||
if (!eventForm.series.type || eventForm.series.type === 'workshop_series') {
|
||||
eventForm.series.type = legacyResponse.type
|
||||
}
|
||||
if (!eventForm.series.totalEvents || eventForm.series.totalEvents === null) {
|
||||
eventForm.series.totalEvents = legacyResponse.totalEvents
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Series doesn't exist yet
|
||||
seriesExists.value = false
|
||||
existingSeries.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const saveEvent = async (redirect = true) => {
|
||||
if (!validateForm()) {
|
||||
|
|
@ -918,24 +783,11 @@ const saveEvent = async (redirect = true) => {
|
|||
creating.value = true
|
||||
try {
|
||||
// If this is a series event and not using an existing series, create the standalone series first
|
||||
if (eventForm.series.isSeriesEvent && eventForm.series.id && !selectedSeriesId.value) {
|
||||
try {
|
||||
await $fetch('/api/admin/series', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
id: eventForm.series.id,
|
||||
title: eventForm.series.title,
|
||||
description: eventForm.series.description,
|
||||
type: eventForm.series.type,
|
||||
totalEvents: eventForm.series.totalEvents
|
||||
}
|
||||
})
|
||||
} catch (seriesError) {
|
||||
// Series might already exist, that's ok
|
||||
if (!seriesError.data?.statusMessage?.includes('already exists')) {
|
||||
throw seriesError
|
||||
}
|
||||
}
|
||||
if (eventForm.series.isSeriesEvent && selectedSeriesId.value) {
|
||||
// Series will be handled by the selected series
|
||||
} else if (eventForm.series.isSeriesEvent) {
|
||||
// For now, series creation requires selecting an existing series
|
||||
// Individual series creation is handled through the series management page
|
||||
}
|
||||
|
||||
if (editingEvent.value) {
|
||||
|
|
@ -1007,10 +859,7 @@ const saveAndCreateAnother = async () => {
|
|||
isSeriesEvent: false,
|
||||
id: '',
|
||||
title: '',
|
||||
description: '',
|
||||
type: 'workshop_series',
|
||||
position: 1,
|
||||
totalEvents: null
|
||||
description: ''
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -196,58 +196,71 @@
|
|||
|
||||
<!-- Registration Form -->
|
||||
<form v-if="registrationStatus !== 'registered'" @submit.prevent="handleRegistration" class="space-y-4">
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Full Name
|
||||
</label>
|
||||
<UInput
|
||||
id="name"
|
||||
v-model="registrationForm.name"
|
||||
type="text"
|
||||
required
|
||||
:disabled="event.membersOnly && !isMember"
|
||||
placeholder="Enter your full name"
|
||||
/>
|
||||
</div>
|
||||
<!-- Show form fields only for public events OR for logged-in members -->
|
||||
<template v-if="!event.membersOnly || isMember">
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Full Name
|
||||
</label>
|
||||
<UInput
|
||||
id="name"
|
||||
v-model="registrationForm.name"
|
||||
type="text"
|
||||
required
|
||||
placeholder="Enter your full name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Email Address
|
||||
</label>
|
||||
<UInput
|
||||
id="email"
|
||||
v-model="registrationForm.email"
|
||||
type="email"
|
||||
required
|
||||
:disabled="event.membersOnly && !isMember"
|
||||
placeholder="Enter your email"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Email Address
|
||||
</label>
|
||||
<UInput
|
||||
id="email"
|
||||
v-model="registrationForm.email"
|
||||
type="email"
|
||||
required
|
||||
placeholder="Enter your email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="membershipLevel" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Membership Status
|
||||
</label>
|
||||
<USelect
|
||||
id="membershipLevel"
|
||||
v-model="registrationForm.membershipLevel"
|
||||
:options="membershipOptions"
|
||||
:disabled="event.membersOnly && !isMember"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="membershipLevel" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Membership Status
|
||||
</label>
|
||||
<USelect
|
||||
id="membershipLevel"
|
||||
v-model="registrationForm.membershipLevel"
|
||||
:options="membershipOptions"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<div class="pt-4">
|
||||
<UButton
|
||||
v-if="!event.membersOnly || isMember"
|
||||
type="submit"
|
||||
color="primary"
|
||||
size="lg"
|
||||
block
|
||||
:disabled="event.membersOnly && !isMember"
|
||||
:loading="isRegistering"
|
||||
>
|
||||
{{ isRegistering ? 'Registering...' : 'Register for Event' }}
|
||||
</UButton>
|
||||
<NuxtLink
|
||||
v-else
|
||||
to="/join"
|
||||
class="block"
|
||||
>
|
||||
<UButton
|
||||
color="primary"
|
||||
size="lg"
|
||||
block
|
||||
>
|
||||
Become a Member to Register
|
||||
</UButton>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
|
@ -302,8 +315,20 @@ if (error.value?.statusCode === 404) {
|
|||
})
|
||||
}
|
||||
|
||||
// Check if user is a member (this would normally come from auth/store)
|
||||
const isMember = ref(false) // Set to true if user is logged in and is a member
|
||||
// Authentication
|
||||
const { isMember, memberData, checkMemberStatus } = useAuth()
|
||||
|
||||
// Check member status on mount
|
||||
onMounted(async () => {
|
||||
await checkMemberStatus()
|
||||
|
||||
// Pre-fill form if member is logged in
|
||||
if (memberData.value) {
|
||||
registrationForm.value.name = memberData.value.name
|
||||
registrationForm.value.email = memberData.value.email
|
||||
registrationForm.value.membershipLevel = memberData.value.membershipLevel || 'non-member'
|
||||
}
|
||||
})
|
||||
|
||||
// Registration form state
|
||||
const registrationForm = ref({
|
||||
|
|
|
|||
|
|
@ -20,7 +20,81 @@
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-2xl p-8 shadow-xl border border-blue-200 dark:border-blue-800">
|
||||
<!-- Step Indicators -->
|
||||
<div class="flex justify-center mb-8">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="flex items-center">
|
||||
<div
|
||||
:class="[
|
||||
'w-10 h-10 rounded-full flex items-center justify-center font-semibold',
|
||||
currentStep >= 1
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-200 text-gray-500'
|
||||
]"
|
||||
>
|
||||
1
|
||||
</div>
|
||||
<span class="ml-2 font-medium" :class="currentStep === 1 ? 'text-blue-600' : 'text-gray-500'">
|
||||
Information
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="needsPayment" class="w-16 h-1 bg-gray-200">
|
||||
<div
|
||||
class="h-full bg-blue-500 transition-all"
|
||||
:style="{ width: currentStep >= 2 ? '100%' : '0%' }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="needsPayment" class="flex items-center">
|
||||
<div
|
||||
:class="[
|
||||
'w-10 h-10 rounded-full flex items-center justify-center font-semibold',
|
||||
currentStep >= 2
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-200 text-gray-500'
|
||||
]"
|
||||
>
|
||||
2
|
||||
</div>
|
||||
<span class="ml-2 font-medium" :class="currentStep === 2 ? 'text-blue-600' : 'text-gray-500'">
|
||||
Payment
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="w-16 h-1 bg-gray-200">
|
||||
<div
|
||||
class="h-full bg-blue-500 transition-all"
|
||||
:style="{ width: currentStep >= 3 ? '100%' : '0%' }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div
|
||||
:class="[
|
||||
'w-10 h-10 rounded-full flex items-center justify-center font-semibold',
|
||||
currentStep >= 3
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-200 text-gray-500'
|
||||
]"
|
||||
>
|
||||
<span v-if="needsPayment">3</span>
|
||||
<span v-else>2</span>
|
||||
</div>
|
||||
<span class="ml-2 font-medium" :class="currentStep === 3 ? 'text-blue-600' : 'text-gray-500'">
|
||||
Confirmation
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div v-if="errorMessage" class="mb-6">
|
||||
<UAlert color="red" :title="errorMessage" />
|
||||
</div>
|
||||
|
||||
<!-- Step 1: Information -->
|
||||
<div v-if="currentStep === 1" class="bg-white dark:bg-gray-800 rounded-2xl p-8 shadow-xl border border-blue-200 dark:border-blue-800">
|
||||
<UForm :state="form" class="space-y-8" @submit="handleSubmit">
|
||||
<!-- Personal Information -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
|
|
@ -113,11 +187,118 @@
|
|||
size="xl"
|
||||
class="px-12"
|
||||
>
|
||||
Continue to Payment
|
||||
{{ needsPayment ? 'Continue to Payment' : 'Complete Registration' }}
|
||||
</UButton>
|
||||
</div>
|
||||
</UForm>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Payment -->
|
||||
<div v-if="currentStep === 2" class="bg-white dark:bg-gray-800 rounded-2xl p-8 shadow-xl border border-blue-200 dark:border-blue-800">
|
||||
<div class="mb-6">
|
||||
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Payment Information
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
You're signing up for the {{ selectedTier.label }} plan
|
||||
</p>
|
||||
<p class="text-lg font-semibold text-blue-600 dark:text-blue-400 mt-2">
|
||||
${{ selectedTier.amount }} CAD / month
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Payment Instructions -->
|
||||
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-6 mb-6">
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
Click "Complete Payment" below to open the secure payment modal and verify your payment method.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex justify-between pt-6">
|
||||
<UButton
|
||||
variant="outline"
|
||||
size="lg"
|
||||
@click="goBack"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
Back
|
||||
</UButton>
|
||||
<UButton
|
||||
size="lg"
|
||||
:loading="isSubmitting"
|
||||
@click="processPayment"
|
||||
>
|
||||
Complete Payment
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Confirmation -->
|
||||
<div v-if="currentStep === 3" class="bg-white dark:bg-gray-800 rounded-2xl p-8 shadow-xl border border-blue-200 dark:border-blue-800">
|
||||
<div class="text-center">
|
||||
<div class="w-20 h-20 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<svg class="w-10 h-10 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Welcome to Ghost Guild!
|
||||
</h3>
|
||||
|
||||
<div v-if="successMessage" class="mb-6">
|
||||
<UAlert color="green" :title="successMessage" />
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-6 mb-6 text-left">
|
||||
<h4 class="font-semibold mb-3">Membership Details:</h4>
|
||||
<dl class="space-y-2">
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-600 dark:text-gray-400">Name:</dt>
|
||||
<dd class="font-medium">{{ form.name }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-600 dark:text-gray-400">Email:</dt>
|
||||
<dd class="font-medium">{{ form.email }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-600 dark:text-gray-400">Circle:</dt>
|
||||
<dd class="font-medium capitalize">{{ form.circle }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-600 dark:text-gray-400">Contribution:</dt>
|
||||
<dd class="font-medium">{{ selectedTier.label }}</dd>
|
||||
</div>
|
||||
<div v-if="customerCode" class="flex justify-between">
|
||||
<dt class="text-gray-600 dark:text-gray-400">Member ID:</dt>
|
||||
<dd class="font-medium">{{ customerCode }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-8">
|
||||
We've sent a confirmation email to {{ form.email }} with your membership details.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<UButton
|
||||
to="/member/dashboard"
|
||||
size="lg"
|
||||
class="px-8"
|
||||
>
|
||||
Go to Dashboard
|
||||
</UButton>
|
||||
<UButton
|
||||
variant="outline"
|
||||
size="lg"
|
||||
@click="resetForm"
|
||||
>
|
||||
Register Another Member
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UContainer>
|
||||
</section>
|
||||
|
||||
|
|
@ -315,9 +496,9 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, computed } from 'vue'
|
||||
import { reactive, ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { getCircleOptions } from '~/config/circles'
|
||||
import { getContributionOptions } from '~/config/contributions'
|
||||
import { getContributionOptions, requiresPayment, getContributionTierByValue } from '~/config/contributions'
|
||||
|
||||
// Form state
|
||||
const form = reactive({
|
||||
|
|
@ -325,10 +506,26 @@ const form = reactive({
|
|||
name: '',
|
||||
circle: 'community',
|
||||
contributionTier: '15',
|
||||
billingAddress: {
|
||||
street: '',
|
||||
city: '',
|
||||
province: '',
|
||||
postalCode: '',
|
||||
country: 'CA'
|
||||
}
|
||||
})
|
||||
|
||||
// UI state
|
||||
const isSubmitting = ref(false)
|
||||
const currentStep = ref(1) // 1: Info, 2: Billing (paid only), 3: Payment, 4: Confirmation
|
||||
const errorMessage = ref('')
|
||||
const successMessage = ref('')
|
||||
|
||||
// Helcim state
|
||||
const customerId = ref(null)
|
||||
const customerCode = ref(null)
|
||||
const subscriptionData = ref(null)
|
||||
const paymentToken = ref(null)
|
||||
|
||||
// Circle options from central config
|
||||
const circleOptions = getCircleOptions()
|
||||
|
|
@ -336,22 +533,199 @@ const circleOptions = getCircleOptions()
|
|||
// Contribution options from central config
|
||||
const contributionOptions = getContributionOptions()
|
||||
|
||||
// Initialize composables
|
||||
const { initializeHelcimPay, verifyPayment, cleanup: cleanupHelcimPay } = useHelcimPay()
|
||||
|
||||
// Form validation
|
||||
const isFormValid = computed(() => {
|
||||
return form.name && form.email && form.circle && form.contributionTier
|
||||
})
|
||||
|
||||
// Form submission - redirect to detailed form
|
||||
const handleSubmit = async () => {
|
||||
if (isSubmitting.value) return
|
||||
// Check if payment is required
|
||||
const needsPayment = computed(() => {
|
||||
return requiresPayment(form.contributionTier)
|
||||
})
|
||||
|
||||
// For now, just scroll to the form or redirect to detailed signup
|
||||
const formElement = document.getElementById('membership-form')
|
||||
if (formElement) {
|
||||
formElement.scrollIntoView({ behavior: 'smooth' })
|
||||
} else {
|
||||
// Could redirect to a detailed form page
|
||||
await navigateTo('/join/details')
|
||||
|
||||
// Get selected tier info
|
||||
const selectedTier = computed(() => {
|
||||
return getContributionTierByValue(form.contributionTier)
|
||||
})
|
||||
|
||||
// Step 1: Create customer
|
||||
const handleSubmit = async () => {
|
||||
if (isSubmitting.value || !isFormValid.value) return
|
||||
|
||||
isSubmitting.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
// Create customer in Helcim
|
||||
const response = await $fetch('/api/helcim/customer', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
name: form.name,
|
||||
email: form.email,
|
||||
circle: form.circle,
|
||||
contributionTier: form.contributionTier,
|
||||
billingAddress: form.billingAddress
|
||||
}
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
console.log('Customer response:', response)
|
||||
customerId.value = response.customerId
|
||||
customerCode.value = response.customerCode
|
||||
|
||||
// Store token in session
|
||||
const authToken = useCookie('auth-token')
|
||||
authToken.value = response.token
|
||||
|
||||
// Move to next step
|
||||
if (needsPayment.value) {
|
||||
currentStep.value = 2
|
||||
// Debug log
|
||||
console.log('Customer ID:', customerId.value, 'Customer Code:', customerCode.value)
|
||||
// Initialize HelcimPay.js session for card verification
|
||||
await initializeHelcimPay(customerId.value, customerCode.value, 0)
|
||||
} else {
|
||||
// For free tier, create subscription directly
|
||||
await createSubscription()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating customer:', error)
|
||||
errorMessage.value = error.data?.message || 'Failed to create account. Please try again.'
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Process payment
|
||||
const processPayment = async () => {
|
||||
if (isSubmitting.value) return
|
||||
|
||||
console.log('Starting payment process...')
|
||||
isSubmitting.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
console.log('Calling verifyPayment()...')
|
||||
// Verify payment through HelcimPay.js
|
||||
const paymentResult = await verifyPayment()
|
||||
console.log('Payment result from HelcimPay:', paymentResult)
|
||||
|
||||
if (paymentResult.success) {
|
||||
paymentToken.value = paymentResult.cardToken
|
||||
console.log('Payment successful, cardToken:', paymentResult.cardToken)
|
||||
|
||||
console.log('Calling verify-payment endpoint...')
|
||||
// Verify payment on server
|
||||
const verifyResult = await $fetch('/api/helcim/verify-payment', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
cardToken: paymentResult.cardToken,
|
||||
customerId: customerId.value
|
||||
}
|
||||
})
|
||||
console.log('Payment verification result:', verifyResult)
|
||||
|
||||
console.log('Calling createSubscription...')
|
||||
// Create subscription (don't let subscription errors prevent form progression)
|
||||
const subscriptionResult = await createSubscription(paymentResult.cardToken)
|
||||
|
||||
if (!subscriptionResult || !subscriptionResult.success) {
|
||||
console.warn('Subscription creation failed but payment succeeded:', subscriptionResult?.error)
|
||||
// Still progress to success page since payment worked
|
||||
currentStep.value = 3
|
||||
successMessage.value = 'Payment successful! Subscription setup may need manual completion.'
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Payment process error:', error)
|
||||
console.error('Error details:', {
|
||||
message: error.message,
|
||||
statusCode: error.statusCode,
|
||||
statusMessage: error.statusMessage,
|
||||
data: error.data
|
||||
})
|
||||
errorMessage.value = error.message || 'Payment verification failed. Please try again.'
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Create subscription
|
||||
const createSubscription = async (cardToken = null) => {
|
||||
try {
|
||||
console.log('Creating subscription with:', {
|
||||
customerId: customerId.value,
|
||||
contributionTier: form.contributionTier,
|
||||
cardToken: cardToken ? 'present' : 'null'
|
||||
})
|
||||
|
||||
const response = await $fetch('/api/helcim/subscription', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
customerId: customerId.value,
|
||||
customerCode: customerCode.value,
|
||||
contributionTier: form.contributionTier,
|
||||
cardToken: cardToken
|
||||
}
|
||||
})
|
||||
|
||||
console.log('Subscription creation response:', response)
|
||||
|
||||
if (response.success) {
|
||||
subscriptionData.value = response.subscription
|
||||
console.log('Moving to step 3 - success!')
|
||||
currentStep.value = 3
|
||||
successMessage.value = 'Your membership has been activated successfully!'
|
||||
} else {
|
||||
throw new Error('Subscription creation failed - response not successful')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Subscription creation error:', error)
|
||||
console.error('Error details:', {
|
||||
message: error.message,
|
||||
statusCode: error.statusCode,
|
||||
statusMessage: error.statusMessage,
|
||||
data: error.data
|
||||
})
|
||||
console.error('Subscription creation completely failed, but payment was successful')
|
||||
// Don't throw error - let the calling function handle progression
|
||||
return {
|
||||
success: false,
|
||||
error: error.data?.message || error.message || 'Failed to create subscription'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Go back to previous step
|
||||
const goBack = () => {
|
||||
if (currentStep.value > 1) {
|
||||
currentStep.value--
|
||||
errorMessage.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// Reset form
|
||||
const resetForm = () => {
|
||||
currentStep.value = 1
|
||||
customerId.value = null
|
||||
customerCode.value = null
|
||||
subscriptionData.value = null
|
||||
paymentToken.value = null
|
||||
errorMessage.value = ''
|
||||
successMessage.value = ''
|
||||
form.email = ''
|
||||
form.name = ''
|
||||
form.circle = 'community'
|
||||
form.contributionTier = '15'
|
||||
}
|
||||
|
||||
// Cleanup on unmount
|
||||
onUnmounted(() => {
|
||||
cleanupHelcimPay()
|
||||
})
|
||||
</script>
|
||||
Loading…
Add table
Add a link
Reference in a new issue