feat(portal): multi-step volunteer registration form with public event endpoint
- Add GET /api/v1/public/events/{slug}/registration-data endpoint for fetching
event sections and time slots without auth
- Create 5-step registration form: personal info, details, motivation, section
preferences, availability
- VeeValidate + Zod validation per step with Dutch error messages
- Auth-aware: pre-fills name/email for authenticated users
- Mobile responsive with custom chip-based step indicator
- Success page with contextual actions (dashboard vs login)
- Types, composable (TanStack Query), and Zod schemas
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
36
apps/portal/src/composables/api/useVolunteerRegistration.ts
Normal file
36
apps/portal/src/composables/api/useVolunteerRegistration.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useQuery, useMutation } from '@tanstack/vue-query'
|
||||
import type { Ref } from 'vue'
|
||||
import { apiClient } from '@/lib/axios'
|
||||
import type { EventRegistrationData, VolunteerRegistrationForm } from '@/types/registration'
|
||||
|
||||
interface ApiResponse<T> {
|
||||
data: T
|
||||
}
|
||||
|
||||
export function useRegistrationData(eventSlug: Ref<string>) {
|
||||
return useQuery({
|
||||
queryKey: ['registration-data', eventSlug],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get<ApiResponse<EventRegistrationData>>(
|
||||
`/public/events/${eventSlug.value}/registration-data`,
|
||||
)
|
||||
|
||||
return data.data
|
||||
},
|
||||
enabled: () => !!eventSlug.value,
|
||||
retry: false,
|
||||
})
|
||||
}
|
||||
|
||||
export function useSubmitRegistration() {
|
||||
return useMutation({
|
||||
mutationFn: async ({ eventId, form }: { eventId: string; form: VolunteerRegistrationForm }) => {
|
||||
const { data } = await apiClient.post<ApiResponse<Record<string, unknown>>>(
|
||||
`/events/${eventId}/volunteer-register`,
|
||||
form,
|
||||
)
|
||||
|
||||
return data.data
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useAuthStore } from '@/stores/useAuthStore'
|
||||
import { useRegistrationData, useSubmitRegistration } from '@/composables/api/useVolunteerRegistration'
|
||||
import { fullRegistrationSchema } from '@/schemas/registrationSchema'
|
||||
import type {
|
||||
SectionPreference,
|
||||
TimeSlotOption,
|
||||
VolunteerAvailability,
|
||||
VolunteerRegistrationForm,
|
||||
} from '@/types/registration'
|
||||
|
||||
definePage({
|
||||
name: 'volunteer-register',
|
||||
meta: {
|
||||
@@ -8,27 +21,662 @@ definePage({
|
||||
})
|
||||
|
||||
const route = useRoute('volunteer-register')
|
||||
const eventSlug = computed(() => route.params.eventSlug)
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const { smAndDown } = useDisplay()
|
||||
|
||||
const eventSlug = computed(() => route.params.eventSlug as string)
|
||||
const { data: registrationData, isLoading, isError } = useRegistrationData(eventSlug)
|
||||
const { mutateAsync: submitRegistration, isPending: isSubmitting } = useSubmitRegistration()
|
||||
|
||||
const currentStep = ref(1)
|
||||
const submitError = ref<string | null>(null)
|
||||
|
||||
// VeeValidate form
|
||||
const { errors, defineField, validateField, setFieldValue } = useForm({
|
||||
validationSchema: toTypedSchema(fullRegistrationSchema),
|
||||
initialValues: {
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
tshirt_size: '',
|
||||
first_aid: false,
|
||||
allergies: '',
|
||||
access_requirements: '',
|
||||
driving_licence: false,
|
||||
motivation: '',
|
||||
motivation_other: '',
|
||||
},
|
||||
})
|
||||
|
||||
const [name] = defineField('name')
|
||||
const [email] = defineField('email')
|
||||
const [phone] = defineField('phone')
|
||||
const [tshirtSize] = defineField('tshirt_size')
|
||||
const [firstAid] = defineField('first_aid')
|
||||
const [allergies] = defineField('allergies')
|
||||
const [accessRequirements] = defineField('access_requirements')
|
||||
const [drivingLicence] = defineField('driving_licence')
|
||||
const [motivation] = defineField('motivation')
|
||||
const [motivationOther] = defineField('motivation_other')
|
||||
|
||||
// Pre-fill authenticated user data
|
||||
watch(() => authStore.user, (user) => {
|
||||
if (user) {
|
||||
setFieldValue('name', user.name)
|
||||
setFieldValue('email', user.email)
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Step 4: Section preferences
|
||||
const selectedSectionIds = ref<string[]>([])
|
||||
|
||||
// Step 5: Availability
|
||||
const selectedTimeSlotIds = ref<string[]>([])
|
||||
const timeSlotPreferences = ref<Record<string, number>>({})
|
||||
|
||||
// Constants
|
||||
const tshirtSizeItems = [
|
||||
{ title: 'Geen voorkeur', value: '' },
|
||||
...['XS', 'S', 'M', 'L', 'XL', 'XXL', 'XXXL'].map(s => ({ title: s, value: s })),
|
||||
]
|
||||
|
||||
const motivationItems = [
|
||||
{ title: 'Gratis festivalpas', value: 'Gratis festivalpas' },
|
||||
{ title: 'Ervaring opdoen', value: 'Ervaring opdoen' },
|
||||
{ title: 'Vrienden helpen', value: 'Vrienden helpen' },
|
||||
{ title: 'CV opbouwen', value: 'CV opbouwen' },
|
||||
{ title: 'Anders', value: 'Anders' },
|
||||
]
|
||||
|
||||
const stepTitles = ['Over jou', 'Meer over jou', 'Motivatie', 'Secties', 'Beschikbaarheid']
|
||||
|
||||
// Computed
|
||||
const timeSlotsByDate = computed(() => {
|
||||
if (!registrationData.value?.time_slots) return []
|
||||
const groups = new Map<string, TimeSlotOption[]>()
|
||||
for (const slot of registrationData.value.time_slots) {
|
||||
if (!groups.has(slot.date)) groups.set(slot.date, [])
|
||||
groups.get(slot.date)!.push(slot)
|
||||
}
|
||||
|
||||
return Array.from(groups.entries()).sort(([a], [b]) => a.localeCompare(b))
|
||||
})
|
||||
|
||||
const totalSelectedHours = computed(() => {
|
||||
if (!registrationData.value?.time_slots) return 0
|
||||
|
||||
return registrationData.value.time_slots
|
||||
.filter(s => selectedTimeSlotIds.value.includes(s.id))
|
||||
.reduce((sum, s) => sum + s.duration_hours, 0)
|
||||
})
|
||||
|
||||
// Step field mapping for validation
|
||||
type FormField = 'name' | 'email' | 'phone' | 'tshirt_size' | 'first_aid' | 'allergies' | 'access_requirements' | 'driving_licence' | 'motivation' | 'motivation_other'
|
||||
|
||||
const stepFields: Record<number, FormField[]> = {
|
||||
1: ['name', 'email', 'phone'],
|
||||
2: ['tshirt_size', 'first_aid', 'allergies', 'access_requirements', 'driving_licence'],
|
||||
3: ['motivation', 'motivation_other'],
|
||||
}
|
||||
|
||||
// Navigation
|
||||
async function validateCurrentStep(): Promise<boolean> {
|
||||
const fields = stepFields[currentStep.value]
|
||||
if (!fields) return true
|
||||
const results = await Promise.all(fields.map(f => validateField(f)))
|
||||
|
||||
return results.every(r => r.valid)
|
||||
}
|
||||
|
||||
async function nextStep() {
|
||||
if (await validateCurrentStep()) {
|
||||
if (currentStep.value < 5) currentStep.value++
|
||||
}
|
||||
}
|
||||
|
||||
function prevStep() {
|
||||
if (currentStep.value > 1) currentStep.value--
|
||||
}
|
||||
|
||||
// Section toggle
|
||||
function toggleSection(sectionId: string) {
|
||||
const idx = selectedSectionIds.value.indexOf(sectionId)
|
||||
if (idx >= 0) {
|
||||
selectedSectionIds.value.splice(idx, 1)
|
||||
}
|
||||
else if (selectedSectionIds.value.length < 5) {
|
||||
selectedSectionIds.value.push(sectionId)
|
||||
}
|
||||
}
|
||||
|
||||
// Time slot toggle
|
||||
function toggleTimeSlot(slotId: string) {
|
||||
const idx = selectedTimeSlotIds.value.indexOf(slotId)
|
||||
if (idx >= 0) {
|
||||
selectedTimeSlotIds.value.splice(idx, 1)
|
||||
delete timeSlotPreferences.value[slotId]
|
||||
}
|
||||
else {
|
||||
selectedTimeSlotIds.value.push(slotId)
|
||||
timeSlotPreferences.value[slotId] = 3
|
||||
}
|
||||
}
|
||||
|
||||
// Helpers
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(`${dateStr}T00:00:00`).toLocaleDateString('nl-NL', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
})
|
||||
}
|
||||
|
||||
function formatTimeRange(start: string, end: string): string {
|
||||
return `${start.slice(0, 5)} – ${end.slice(0, 5)}`
|
||||
}
|
||||
|
||||
// Submit
|
||||
async function onSubmit() {
|
||||
submitError.value = null
|
||||
|
||||
// Validate steps 1-3
|
||||
for (let step = 1; step <= 3; step++) {
|
||||
const fields = stepFields[step]
|
||||
if (!fields) continue
|
||||
const results = await Promise.all(fields.map(f => validateField(f)))
|
||||
if (!results.every(r => r.valid)) {
|
||||
currentStep.value = step
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!registrationData.value) return
|
||||
|
||||
const sectionPreferences: SectionPreference[] = selectedSectionIds.value.map((id, index) => ({
|
||||
section_id: id,
|
||||
priority: index + 1,
|
||||
}))
|
||||
|
||||
const availabilities: VolunteerAvailability[] = selectedTimeSlotIds.value.map(id => ({
|
||||
time_slot_id: id,
|
||||
preference_level: timeSlotPreferences.value[id] ?? 3,
|
||||
}))
|
||||
|
||||
const payload: VolunteerRegistrationForm = {
|
||||
name: name.value ?? '',
|
||||
email: email.value ?? '',
|
||||
phone: phone.value ?? '',
|
||||
tshirt_size: tshirtSize.value ?? '',
|
||||
first_aid: firstAid.value ?? false,
|
||||
allergies: allergies.value ?? '',
|
||||
access_requirements: accessRequirements.value ?? '',
|
||||
driving_licence: drivingLicence.value ?? false,
|
||||
motivation: motivation.value ?? '',
|
||||
motivation_other: motivationOther.value ?? '',
|
||||
section_preferences: sectionPreferences,
|
||||
availabilities,
|
||||
}
|
||||
|
||||
try {
|
||||
await submitRegistration({
|
||||
eventId: registrationData.value.event.id,
|
||||
form: payload,
|
||||
})
|
||||
|
||||
router.push({
|
||||
path: '/register/success',
|
||||
query: {
|
||||
event: registrationData.value.event.name,
|
||||
authenticated: authStore.isAuthenticated ? '1' : '0',
|
||||
},
|
||||
})
|
||||
}
|
||||
catch (error: unknown) {
|
||||
const axiosError = error as { response?: { status?: number; data?: { errors?: Record<string, string[]> } } }
|
||||
if (axiosError.response?.status === 422) {
|
||||
const serverErrors = axiosError.response.data?.errors
|
||||
if (serverErrors) {
|
||||
for (const field of Object.keys(serverErrors)) {
|
||||
for (const [step, fields] of Object.entries(stepFields)) {
|
||||
if (fields.includes(field as FormField)) {
|
||||
currentStep.value = Number(step)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
submitError.value = 'Er zijn validatiefouten gevonden. Controleer je invoer.'
|
||||
}
|
||||
else {
|
||||
submitError.value = 'Er is een fout opgetreden. Probeer het opnieuw.'
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VRow justify="center">
|
||||
<!-- Loading -->
|
||||
<VRow
|
||||
v-if="isLoading"
|
||||
justify="center"
|
||||
>
|
||||
<VCol
|
||||
cols="12"
|
||||
md="10"
|
||||
lg="8"
|
||||
>
|
||||
<VCard class="pa-6">
|
||||
<VSkeletonLoader type="heading" />
|
||||
<VSkeletonLoader
|
||||
type="text@3"
|
||||
class="mt-4"
|
||||
/>
|
||||
<VSkeletonLoader
|
||||
type="button"
|
||||
class="mt-4"
|
||||
/>
|
||||
</VCard>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
<!-- Error / Not available -->
|
||||
<VRow
|
||||
v-else-if="isError || !registrationData"
|
||||
justify="center"
|
||||
>
|
||||
<VCol
|
||||
cols="12"
|
||||
md="8"
|
||||
lg="6"
|
||||
>
|
||||
<VCard class="text-center pa-6">
|
||||
<VCard class="text-center pa-8">
|
||||
<VIcon
|
||||
icon="tabler-calendar-off"
|
||||
size="64"
|
||||
color="warning"
|
||||
class="mb-4"
|
||||
/>
|
||||
<VCardTitle class="text-h5">
|
||||
Niet beschikbaar
|
||||
</VCardTitle>
|
||||
<VCardText class="text-body-1">
|
||||
Dit evenement accepteert momenteel geen aanmeldingen.
|
||||
</VCardText>
|
||||
<VCardActions class="justify-center">
|
||||
<VBtn
|
||||
to="/"
|
||||
variant="outlined"
|
||||
>
|
||||
Terug naar startpagina
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
<!-- Registration Form -->
|
||||
<VRow
|
||||
v-else
|
||||
justify="center"
|
||||
>
|
||||
<VCol
|
||||
cols="12"
|
||||
md="10"
|
||||
lg="8"
|
||||
>
|
||||
<VCard>
|
||||
<VCardTitle class="text-h5 pa-4 pa-sm-6 pb-2">
|
||||
Aanmelden als vrijwilliger
|
||||
</VCardTitle>
|
||||
<VCardSubtitle>
|
||||
Vul het formulier in om je aan te melden
|
||||
<VCardSubtitle class="px-4 px-sm-6 pb-4">
|
||||
{{ registrationData.event.name }}
|
||||
</VCardSubtitle>
|
||||
<VCardText class="text-body-1 mt-4">
|
||||
Evenement: <strong>{{ eventSlug }}</strong>
|
||||
</VCardText>
|
||||
|
||||
<VAlert
|
||||
v-if="authStore.isAuthenticated"
|
||||
type="info"
|
||||
variant="tonal"
|
||||
class="mx-4 mx-sm-6"
|
||||
>
|
||||
Je bent ingelogd als {{ authStore.user?.name }}. Je gegevens zijn automatisch ingevuld.
|
||||
</VAlert>
|
||||
|
||||
<VAlert
|
||||
v-if="submitError"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
class="mx-4 mx-sm-6 mt-4"
|
||||
closable
|
||||
@click:close="submitError = null"
|
||||
>
|
||||
{{ submitError }}
|
||||
</VAlert>
|
||||
|
||||
<!-- Step indicator -->
|
||||
<div class="d-flex flex-wrap align-center justify-center ga-1 px-4 px-sm-6 pt-6">
|
||||
<template
|
||||
v-for="(title, i) in stepTitles"
|
||||
:key="i"
|
||||
>
|
||||
<VChip
|
||||
:color="currentStep === i + 1 ? 'primary' : currentStep > i + 1 ? 'success' : undefined"
|
||||
:variant="currentStep === i + 1 ? 'elevated' : currentStep > i + 1 ? 'tonal' : 'outlined'"
|
||||
size="small"
|
||||
class="step-chip"
|
||||
@click="i + 1 < currentStep ? currentStep = i + 1 : undefined"
|
||||
>
|
||||
<VIcon
|
||||
v-if="currentStep > i + 1"
|
||||
icon="tabler-check"
|
||||
size="14"
|
||||
start
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="text-caption font-weight-bold me-1"
|
||||
>{{ i + 1 }}</span>
|
||||
<span v-if="!smAndDown || currentStep === i + 1">{{ title }}</span>
|
||||
</VChip>
|
||||
<VIcon
|
||||
v-if="i < 4"
|
||||
icon="tabler-chevron-right"
|
||||
size="14"
|
||||
class="text-disabled d-none d-sm-inline-flex"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Step content -->
|
||||
<VWindow v-model="currentStep">
|
||||
<!-- Step 1: Over jou -->
|
||||
<VWindowItem :value="1">
|
||||
<div class="pa-4 pa-sm-6">
|
||||
<VTextField
|
||||
v-model="name"
|
||||
label="Naam *"
|
||||
:error-messages="errors.name"
|
||||
:disabled="authStore.isAuthenticated"
|
||||
autofocus
|
||||
class="mb-4"
|
||||
/>
|
||||
<VTextField
|
||||
v-model="email"
|
||||
label="E-mailadres *"
|
||||
type="email"
|
||||
:error-messages="errors.email"
|
||||
:disabled="authStore.isAuthenticated"
|
||||
class="mb-4"
|
||||
/>
|
||||
<VTextField
|
||||
v-model="phone"
|
||||
label="Telefoonnummer"
|
||||
:error-messages="errors.phone"
|
||||
/>
|
||||
</div>
|
||||
</VWindowItem>
|
||||
|
||||
<!-- Step 2: Meer over jou -->
|
||||
<VWindowItem :value="2">
|
||||
<div class="pa-4 pa-sm-6">
|
||||
<VSelect
|
||||
v-model="tshirtSize"
|
||||
:items="tshirtSizeItems"
|
||||
label="Shirtmaat"
|
||||
:error-messages="errors.tshirt_size"
|
||||
class="mb-4"
|
||||
/>
|
||||
<VSwitch
|
||||
v-model="firstAid"
|
||||
label="Ik heb een EHBO-diploma"
|
||||
color="primary"
|
||||
hide-details
|
||||
class="mb-4"
|
||||
/>
|
||||
<VTextarea
|
||||
v-model="allergies"
|
||||
label="Allergieën"
|
||||
:error-messages="errors.allergies"
|
||||
:counter="500"
|
||||
rows="2"
|
||||
auto-grow
|
||||
class="mb-4"
|
||||
/>
|
||||
<VTextarea
|
||||
v-model="accessRequirements"
|
||||
label="Toegangsbehoeften"
|
||||
:error-messages="errors.access_requirements"
|
||||
hint="Bijv. rolstoeltoegankelijk, rustige werkplek, etc."
|
||||
persistent-hint
|
||||
:counter="500"
|
||||
rows="2"
|
||||
auto-grow
|
||||
class="mb-4"
|
||||
/>
|
||||
<VSwitch
|
||||
v-model="drivingLicence"
|
||||
label="Ik heb een rijbewijs B"
|
||||
color="primary"
|
||||
hide-details
|
||||
/>
|
||||
</div>
|
||||
</VWindowItem>
|
||||
|
||||
<!-- Step 3: Motivatie -->
|
||||
<VWindowItem :value="3">
|
||||
<div class="pa-4 pa-sm-6">
|
||||
<VSelect
|
||||
v-model="motivation"
|
||||
:items="motivationItems"
|
||||
label="Wat is je motivatie?"
|
||||
:error-messages="errors.motivation"
|
||||
clearable
|
||||
class="mb-4"
|
||||
/>
|
||||
<VTextarea
|
||||
v-if="motivation"
|
||||
v-model="motivationOther"
|
||||
label="Toelichting"
|
||||
:error-messages="errors.motivation_other"
|
||||
:counter="500"
|
||||
rows="3"
|
||||
auto-grow
|
||||
/>
|
||||
</div>
|
||||
</VWindowItem>
|
||||
|
||||
<!-- Step 4: Voorkeurssecties -->
|
||||
<VWindowItem :value="4">
|
||||
<div class="pa-4 pa-sm-6">
|
||||
<p
|
||||
v-if="registrationData.sections.length === 0"
|
||||
class="text-body-1 text-medium-emphasis"
|
||||
>
|
||||
Er zijn geen secties beschikbaar voor dit evenement.
|
||||
</p>
|
||||
<template v-else>
|
||||
<p class="text-body-2 text-medium-emphasis mb-4">
|
||||
Selecteer maximaal 5 secties waar je graag wilt werken.
|
||||
De volgorde van selectie bepaalt je voorkeur.
|
||||
</p>
|
||||
<VList lines="two">
|
||||
<VListItem
|
||||
v-for="section in registrationData.sections"
|
||||
:key="section.id"
|
||||
:disabled="!selectedSectionIds.includes(section.id) && selectedSectionIds.length >= 5"
|
||||
class="section-item"
|
||||
@click="toggleSection(section.id)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VCheckboxBtn
|
||||
:model-value="selectedSectionIds.includes(section.id)"
|
||||
:disabled="!selectedSectionIds.includes(section.id) && selectedSectionIds.length >= 5"
|
||||
@click.stop="toggleSection(section.id)"
|
||||
/>
|
||||
</template>
|
||||
<VListItemTitle class="d-flex align-center ga-2">
|
||||
<VIcon
|
||||
v-if="section.icon"
|
||||
:icon="section.icon"
|
||||
size="18"
|
||||
/>
|
||||
{{ section.name }}
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle v-if="section.category">
|
||||
<VChip
|
||||
size="x-small"
|
||||
variant="outlined"
|
||||
>
|
||||
{{ section.category }}
|
||||
</VChip>
|
||||
</VListItemSubtitle>
|
||||
<template
|
||||
v-if="selectedSectionIds.includes(section.id)"
|
||||
#append
|
||||
>
|
||||
<VChip
|
||||
color="primary"
|
||||
size="small"
|
||||
>
|
||||
Voorkeur {{ selectedSectionIds.indexOf(section.id) + 1 }}
|
||||
</VChip>
|
||||
</template>
|
||||
</VListItem>
|
||||
</VList>
|
||||
|
||||
<p
|
||||
v-if="selectedSectionIds.length > 0"
|
||||
class="text-body-2 text-medium-emphasis mt-4"
|
||||
>
|
||||
{{ selectedSectionIds.length }} van 5 secties geselecteerd
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</VWindowItem>
|
||||
|
||||
<!-- Step 5: Beschikbaarheid -->
|
||||
<VWindowItem :value="5">
|
||||
<div class="pa-4 pa-sm-6">
|
||||
<p
|
||||
v-if="registrationData.time_slots.length === 0"
|
||||
class="text-body-1 text-medium-emphasis"
|
||||
>
|
||||
Er zijn geen tijdsloten beschikbaar voor dit evenement.
|
||||
</p>
|
||||
<template v-else>
|
||||
<p class="text-body-2 text-medium-emphasis mb-4">
|
||||
Selecteer de tijdsloten waarop je beschikbaar bent en geef je voorkeur aan met sterren.
|
||||
</p>
|
||||
<div
|
||||
v-for="[date, slots] in timeSlotsByDate"
|
||||
:key="date"
|
||||
class="mb-6"
|
||||
>
|
||||
<h4 class="text-subtitle-1 font-weight-bold mb-2 text-capitalize">
|
||||
{{ formatDate(date) }}
|
||||
</h4>
|
||||
<VList density="compact">
|
||||
<VListItem
|
||||
v-for="slot in slots"
|
||||
:key="slot.id"
|
||||
class="timeslot-item"
|
||||
@click="toggleTimeSlot(slot.id)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VCheckboxBtn
|
||||
:model-value="selectedTimeSlotIds.includes(slot.id)"
|
||||
@click.stop="toggleTimeSlot(slot.id)"
|
||||
/>
|
||||
</template>
|
||||
<VListItemTitle>{{ slot.name }}</VListItemTitle>
|
||||
<VListItemSubtitle>
|
||||
{{ formatTimeRange(slot.start_time, slot.end_time) }} · {{ slot.duration_hours }}u
|
||||
</VListItemSubtitle>
|
||||
<template
|
||||
v-if="selectedTimeSlotIds.includes(slot.id)"
|
||||
#append
|
||||
>
|
||||
<VRating
|
||||
v-model="timeSlotPreferences[slot.id]"
|
||||
density="compact"
|
||||
size="small"
|
||||
length="5"
|
||||
color="warning"
|
||||
active-color="warning"
|
||||
@click.stop
|
||||
/>
|
||||
</template>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</div>
|
||||
|
||||
<VDivider class="mb-4" />
|
||||
|
||||
<div class="d-flex align-center">
|
||||
<span class="text-body-1">
|
||||
Totaal geselecteerd: <strong>{{ totalSelectedHours }} uur</strong>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<VAlert
|
||||
v-if="selectedTimeSlotIds.length > 0 && totalSelectedHours < 8"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
class="mt-4"
|
||||
>
|
||||
Minimaal 8 uur nodig voor een festivalpas.
|
||||
</VAlert>
|
||||
</template>
|
||||
</div>
|
||||
</VWindowItem>
|
||||
</VWindow>
|
||||
|
||||
<VDivider />
|
||||
|
||||
<!-- Navigation -->
|
||||
<div class="d-flex align-center pa-4 pa-sm-6">
|
||||
<VBtn
|
||||
v-if="currentStep > 1"
|
||||
variant="text"
|
||||
prepend-icon="tabler-arrow-left"
|
||||
@click="prevStep"
|
||||
>
|
||||
Vorige
|
||||
</VBtn>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
v-if="currentStep < 5"
|
||||
color="primary"
|
||||
append-icon="tabler-arrow-right"
|
||||
@click="nextStep"
|
||||
>
|
||||
Volgende
|
||||
</VBtn>
|
||||
<VBtn
|
||||
v-else
|
||||
color="primary"
|
||||
:loading="isSubmitting"
|
||||
prepend-icon="tabler-send"
|
||||
@click="onSubmit"
|
||||
>
|
||||
Aanmelding versturen
|
||||
</VBtn>
|
||||
</div>
|
||||
</VCard>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.step-chip {
|
||||
cursor: pointer;
|
||||
min-block-size: 32px;
|
||||
}
|
||||
|
||||
.section-item,
|
||||
.timeslot-item {
|
||||
min-block-size: 48px;
|
||||
}
|
||||
</style>
|
||||
|
||||
68
apps/portal/src/pages/register/success.vue
Normal file
68
apps/portal/src/pages/register/success.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from '@/stores/useAuthStore'
|
||||
|
||||
definePage({
|
||||
name: 'register-success',
|
||||
meta: {
|
||||
layout: 'portal',
|
||||
requiresAuth: false,
|
||||
},
|
||||
})
|
||||
|
||||
const route = useRoute('register-success')
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const eventName = computed(() => (route.query.event as string) || 'het evenement')
|
||||
const isAuthenticated = computed(() => route.query.authenticated === '1' || authStore.isAuthenticated)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VRow justify="center">
|
||||
<VCol
|
||||
cols="12"
|
||||
md="8"
|
||||
lg="6"
|
||||
>
|
||||
<VCard class="text-center pa-8">
|
||||
<VIcon
|
||||
icon="tabler-circle-check"
|
||||
size="80"
|
||||
color="success"
|
||||
class="mb-4"
|
||||
/>
|
||||
<VCardTitle class="text-h5 mb-2">
|
||||
Bedankt voor je aanmelding!
|
||||
</VCardTitle>
|
||||
<VCardText class="text-body-1">
|
||||
<p class="mb-4">
|
||||
Bedankt voor je aanmelding bij <strong>{{ eventName }}</strong>!
|
||||
</p>
|
||||
<p class="mb-4">
|
||||
Je aanmelding wordt beoordeeld door het organisatieteam.
|
||||
</p>
|
||||
<p class="text-medium-emphasis">
|
||||
Je ontvangt een e-mail zodra je aanmelding is goedgekeurd.
|
||||
</p>
|
||||
</VCardText>
|
||||
<VCardActions class="justify-center pt-4">
|
||||
<VBtn
|
||||
v-if="isAuthenticated"
|
||||
to="/dashboard"
|
||||
color="primary"
|
||||
prepend-icon="tabler-dashboard"
|
||||
>
|
||||
Ga naar je dashboard
|
||||
</VBtn>
|
||||
<VBtn
|
||||
v-else
|
||||
to="/login"
|
||||
variant="outlined"
|
||||
prepend-icon="tabler-login"
|
||||
>
|
||||
Heb je al een account? Log in
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</template>
|
||||
24
apps/portal/src/schemas/registrationSchema.ts
Normal file
24
apps/portal/src/schemas/registrationSchema.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const step1Schema = z.object({
|
||||
name: z.string().min(1, 'Naam is verplicht').max(255),
|
||||
email: z.string().min(1, 'E-mailadres is verplicht').email('Ongeldig e-mailadres').max(255),
|
||||
phone: z.string().max(50).optional().or(z.literal('')),
|
||||
})
|
||||
|
||||
export const step2Schema = z.object({
|
||||
tshirt_size: z.enum(['XS', 'S', 'M', 'L', 'XL', 'XXL', 'XXXL']).optional().or(z.literal('')),
|
||||
first_aid: z.boolean().default(false),
|
||||
allergies: z.string().max(500).optional().or(z.literal('')),
|
||||
access_requirements: z.string().max(500).optional().or(z.literal('')),
|
||||
driving_licence: z.boolean().default(false),
|
||||
})
|
||||
|
||||
export const step3Schema = z.object({
|
||||
motivation: z.string().max(1000).optional().or(z.literal('')),
|
||||
motivation_other: z.string().max(500).optional().or(z.literal('')),
|
||||
})
|
||||
|
||||
export const fullRegistrationSchema = step1Schema
|
||||
.merge(step2Schema)
|
||||
.merge(step3Schema)
|
||||
57
apps/portal/src/types/registration.ts
Normal file
57
apps/portal/src/types/registration.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
export interface EventRegistrationData {
|
||||
event: {
|
||||
id: string
|
||||
name: string
|
||||
start_date: string
|
||||
end_date: string
|
||||
organisation_id: string
|
||||
}
|
||||
sections: SectionOption[]
|
||||
time_slots: TimeSlotOption[]
|
||||
}
|
||||
|
||||
export interface SectionOption {
|
||||
id: string
|
||||
name: string
|
||||
category: string | null
|
||||
icon: string | null
|
||||
}
|
||||
|
||||
export interface TimeSlotOption {
|
||||
id: string
|
||||
name: string
|
||||
date: string
|
||||
start_time: string
|
||||
end_time: string
|
||||
duration_hours: number
|
||||
}
|
||||
|
||||
export interface SectionPreference {
|
||||
section_id: string
|
||||
priority: number
|
||||
}
|
||||
|
||||
export interface VolunteerAvailability {
|
||||
time_slot_id: string
|
||||
preference_level: number
|
||||
}
|
||||
|
||||
export interface VolunteerRegistrationForm {
|
||||
// Step 1
|
||||
name: string
|
||||
email: string
|
||||
phone: string
|
||||
// Step 2
|
||||
tshirt_size: string
|
||||
first_aid: boolean
|
||||
allergies: string
|
||||
access_requirements: string
|
||||
driving_licence: boolean
|
||||
// Step 3
|
||||
motivation: string
|
||||
motivation_other: string
|
||||
// Step 4
|
||||
section_preferences: SectionPreference[]
|
||||
// Step 5
|
||||
availabilities: VolunteerAvailability[]
|
||||
}
|
||||
Reference in New Issue
Block a user