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:
Jennie Robinson Faber 2025-09-03 14:47:13 +01:00
parent a88aa62198
commit 2ca290d6e0
22 changed files with 1994 additions and 213 deletions

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

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

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

View file

@ -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: ''
}
})

View file

@ -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({

View file

@ -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>