Files
crewli/apps/portal/src/pages/register/[eventSlug].vue

1631 lines
54 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { watchDebounced } from '@vueuse/core'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { useDisplay } from 'vuetify'
import { apiClient } from '@/lib/axios'
import { useAuthStore } from '@/stores/useAuthStore'
import { useRegistrationData, useSubmitRegistration } from '@/composables/api/useVolunteerRegistration'
import { fullRegistrationSchema } from '@/schemas/registrationSchema'
import type {
RegistrationField,
RegistrationFieldType,
SectionOption,
SectionPreference,
TimeSlotOption,
VolunteerAvailability,
VolunteerRegistrationForm,
} from '@/types/registration'
definePage({
name: 'volunteer-register',
meta: {
layout: 'blank',
requiresAuth: false,
},
})
const route = useRoute('volunteer-register')
const router = useRouter()
const authStore = useAuthStore()
const { mdAndUp } = 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(0)
const submitError = ref<string | null>(null)
const fieldFormData = ref<Record<string, unknown>>({})
const fieldErrors = ref<Record<string, string>>({})
const password = ref('')
const passwordConfirmation = ref('')
const emailExists = ref<boolean | null>(null)
const emailChecking = ref(false)
const lastCheckedEmail = ref('')
let checkEmailRequestSeq = 0
const showPassword = ref(false)
const showPasswordConfirmation = ref(false)
const passwordServerError = ref('')
const passwordConfirmationServerError = ref('')
const passwordClientError = ref('')
const passwordConfirmationClientError = ref('')
const { errors, defineField, validateField, setFieldValue, setFieldError } = useForm({
validationSchema: toTypedSchema(fullRegistrationSchema),
initialValues: {
first_name: '',
last_name: '',
email: '',
date_of_birth: '',
phone: '',
},
})
const [firstName] = defineField('first_name')
const [lastName] = defineField('last_name')
const [dateOfBirth] = defineField('date_of_birth')
const [email] = defineField('email')
const [phone] = defineField('phone')
// Pre-fill authenticated user data
watch(() => authStore.user, user => {
if (user) {
setFieldValue('first_name', user.first_name)
setFieldValue('last_name', user.last_name)
setFieldValue('email', user.email)
}
}, { immediate: true })
function isEmailFormatForCheck(value: string): boolean {
return value.includes('@') && value.includes('.')
}
async function runCheckEmail(rawEmail: string): Promise<void> {
if (authStore.isAuthenticated) return
const trimmed = rawEmail.trim()
if (!isEmailFormatForCheck(trimmed)) {
emailExists.value = null
lastCheckedEmail.value = ''
emailChecking.value = false
return
}
const seq = ++checkEmailRequestSeq
emailChecking.value = true
try {
const { data } = await apiClient.post<{ exists: boolean }>('/public/check-email', { email: trimmed })
if (seq !== checkEmailRequestSeq) return
emailExists.value = data.exists
lastCheckedEmail.value = trimmed
}
catch {
if (seq !== checkEmailRequestSeq) return
emailExists.value = null
}
finally {
if (seq === checkEmailRequestSeq)
emailChecking.value = false
}
}
function onEmailBlur(): void {
if (authStore.isAuthenticated) return
const trimmed = (email.value ?? '').trim()
if (!isEmailFormatForCheck(trimmed)) return
if (emailChecking.value) return
if (trimmed === lastCheckedEmail.value && emailExists.value !== null) return
void runCheckEmail(trimmed)
}
watchDebounced(
email,
val => {
void runCheckEmail(val ?? '')
},
{ debounce: 500 },
)
watch(email, val => {
if (authStore.isAuthenticated) return
const t = (val ?? '').trim()
if (t !== lastCheckedEmail.value) {
emailExists.value = null
password.value = ''
passwordConfirmation.value = ''
passwordServerError.value = ''
passwordConfirmationServerError.value = ''
passwordClientError.value = ''
passwordConfirmationClientError.value = ''
}
})
watch(() => authStore.isAuthenticated, authed => {
if (!authed) return
emailExists.value = null
emailChecking.value = false
lastCheckedEmail.value = ''
password.value = ''
passwordConfirmation.value = ''
passwordServerError.value = ''
passwordConfirmationServerError.value = ''
passwordClientError.value = ''
passwordConfirmationClientError.value = ''
})
watch(password, () => {
passwordServerError.value = ''
passwordClientError.value = ''
})
watch(passwordConfirmation, () => {
passwordConfirmationServerError.value = ''
passwordConfirmationClientError.value = ''
})
function validatePasswordsForStep(): boolean {
passwordClientError.value = ''
passwordConfirmationClientError.value = ''
if (authStore.isAuthenticated) return true
if (emailChecking.value) return false
const em = (email.value ?? '').trim()
if (!isEmailFormatForCheck(em)) return true
if (emailExists.value === null) return false
if (emailExists.value === true) {
if (!password.value) {
passwordClientError.value = 'Verplicht'
return false
}
return true
}
if (!password.value) {
passwordClientError.value = 'Verplicht'
return false
}
if (password.value.length < 8) {
passwordClientError.value = 'Minimaal 8 tekens'
return false
}
if (password.value !== passwordConfirmation.value) {
passwordConfirmationClientError.value = 'Wachtwoorden komen niet overeen'
return false
}
return true
}
const selectedSectionIds = ref<string[]>([])
const selectedTimeSlotIds = ref<string[]>([])
const timeSlotPreferences = ref<Record<string, number>>({})
const showSections = computed(() => Boolean(registrationData.value?.event.registration_show_section_preferences))
const showAvailability = computed(() => Boolean(registrationData.value?.event.registration_show_availability))
const steps = computed(() => {
const list = [
{ title: 'Over jou', subtitle: 'Vul je persoonlijke gegevens in' },
{ title: 'Extra informatie', subtitle: 'Antwoord op de vragen van de organisatie' },
]
if (showSections.value)
list.push({ title: 'Secties', subtitle: 'Waar wil je het liefst werken?' })
if (showAvailability.value)
list.push({ title: 'Beschikbaarheid', subtitle: 'Wanneer kun je helpen?' })
return list
})
const sectionsStepIndex = computed(() => (showSections.value ? 2 : -1))
const availabilityStepIndex = computed(() => {
if (!showAvailability.value) return -1
return showSections.value ? 3 : 2
})
const currentStepKind = computed(() => {
if (currentStep.value === 0) return 'personal'
if (currentStep.value === 1) return 'dynamic'
if (sectionsStepIndex.value !== -1 && currentStep.value === sectionsStepIndex.value) return 'sections'
if (availabilityStepIndex.value !== -1 && currentStep.value === availabilityStepIndex.value) return 'availability'
return 'personal'
})
const registrationFieldsList = computed(() => registrationData.value?.registration_fields ?? [])
const groupedRegistrationFields = computed(() => {
const fields = registrationFieldsList.value
const groups: { section: string | null; fields: RegistrationField[] }[] = []
for (const f of fields) {
const last = groups[groups.length - 1]
if (last && last.section === f.section)
last.fields.push(f)
else
groups.push({ section: f.section, fields: [f] })
}
return groups
})
function defaultFieldValue(type: RegistrationFieldType): unknown {
switch (type) {
case 'multiselect':
case 'checkbox':
case 'tag_picker':
return []
case 'boolean':
return false
case 'number':
return null
default:
return ''
}
}
watch(registrationFieldsList, fields => {
for (const f of fields) {
if (!(f.slug in fieldFormData.value))
fieldFormData.value[f.slug] = defaultFieldValue(f.field_type)
}
}, { immediate: true })
watch(steps, s => {
if (currentStep.value >= s.length)
currentStep.value = Math.max(0, s.length - 1)
})
// Section helpers
const sectionsByCategory = computed(() => {
if (!registrationData.value?.sections) return {}
return registrationData.value.sections.reduce((groups, section) => {
const cat = section.category || 'Overig'
if (!groups[cat]) groups[cat] = []
groups[cat].push(section)
return groups
}, {} as Record<string, SectionOption[]>)
})
function isSelected(sectionId: string) {
return selectedSectionIds.value.includes(sectionId)
}
function getSelectionPriority(sectionId: string) {
return selectedSectionIds.value.indexOf(sectionId) + 1
}
const selectedCount = computed(() => selectedSectionIds.value.length)
// 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
const total = registrationData.value.time_slots
.filter(s => selectedTimeSlotIds.value.includes(s.id))
.reduce((sum, s) => sum + Number(s.duration_hours), 0)
return Math.round(total * 100) / 100
})
const formattedDates = computed(() => {
if (!registrationData.value) return ''
return formatDateRange(
registrationData.value.event.start_date,
registrationData.value.event.end_date,
)
})
const personalFieldKeys = ['first_name', 'last_name', 'date_of_birth', 'email', 'phone'] as const
function isMultiValueType(t: RegistrationFieldType): boolean {
return t === 'multiselect' || t === 'checkbox' || t === 'tag_picker'
}
function isEmptyFieldValue(value: unknown, type: RegistrationFieldType): boolean {
if (value === undefined || value === null) return true
if (value === '') return true
if (type === 'number' && (typeof value !== 'number' || Number.isNaN(value))) return true
if (isMultiValueType(type)) {
if (!Array.isArray(value)) return true
return value.length === 0
}
return false
}
function ensureCheckboxArray(slug: string): string[] {
const v = fieldFormData.value[slug]
if (Array.isArray(v)) return v.map(String)
fieldFormData.value[slug] = []
return fieldFormData.value[slug] as string[]
}
function toggleCheckboxOption(slug: string, option: string, checked: boolean | null) {
const arr = ensureCheckboxArray(slug)
const on = Boolean(checked)
if (on) {
if (!arr.includes(option)) arr.push(option)
}
else {
const i = arr.indexOf(option)
if (i !== -1) arr.splice(i, 1)
}
fieldFormData.value[slug] = [...arr]
}
function isCheckboxChecked(slug: string, option: string): boolean {
const v = fieldFormData.value[slug]
return Array.isArray(v) && v.includes(option)
}
function numberFieldModel(slug: string): string {
const v = fieldFormData.value[slug]
if (v === null || v === undefined) return ''
if (typeof v === 'number' && Number.isNaN(v)) return ''
return String(v)
}
function onNumberFieldInput(slug: string, raw: string) {
if (raw === '' || raw === '-') {
fieldFormData.value[slug] = null
return
}
const n = Number(raw)
fieldFormData.value[slug] = Number.isFinite(n) ? n : null
}
function validateDynamicFields(): boolean {
fieldErrors.value = {}
let firstErrorSlug: string | null = null
for (const field of registrationFieldsList.value) {
if (!field.is_required) continue
const val = fieldFormData.value[field.slug]
if (isEmptyFieldValue(val, field.field_type)) {
fieldErrors.value[field.slug] = 'Dit veld is verplicht.'
firstErrorSlug ??= field.slug
}
}
if (firstErrorSlug) {
nextTick(() => {
document.getElementById(`reg-field-${firstErrorSlug}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' })
})
return false
}
return true
}
async function validateCurrentStep(): Promise<boolean> {
const k = currentStepKind.value
if (k === 'personal') {
const results = await Promise.all(personalFieldKeys.map(f => validateField(f)))
if (!results.every(r => r.valid)) return false
return validatePasswordsForStep()
}
if (k === 'dynamic')
return validateDynamicFields()
return true
}
async function nextStep() {
if (await validateCurrentStep()) {
if (currentStep.value < steps.value.length - 1) currentStep.value++
}
}
function prevStep() {
if (currentStep.value > 0) currentStep.value--
}
function goToStep(index: number) {
if (index < currentStep.value) {
currentStep.value = index
}
}
function toggleSection(sectionId: string) {
const idx = selectedSectionIds.value.indexOf(sectionId)
if (idx !== -1) {
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 formatDateRange(start: string, end: string): string {
const startDate = new Date(`${start}T00:00:00`)
const endDate = new Date(`${end}T00:00:00`)
if (startDate.getMonth() === endDate.getMonth() && startDate.getFullYear() === endDate.getFullYear()) {
return `${startDate.getDate()} ${endDate.toLocaleDateString('nl-NL', { day: 'numeric', month: 'long', year: 'numeric' })}`
}
return `${startDate.toLocaleDateString('nl-NL', { day: 'numeric', month: 'long' })} ${endDate.toLocaleDateString('nl-NL', { day: 'numeric', month: 'long', year: 'numeric' })}`
}
function formatTimeRange(start: string, end: string): string {
return `${start.slice(0, 5)} ${end.slice(0, 5)}`
}
function buildFieldValuesPayload(): Record<string, unknown> | undefined {
const out: Record<string, unknown> = {}
for (const field of registrationFieldsList.value) {
const val = fieldFormData.value[field.slug]
if (field.field_type === 'boolean') {
if (typeof val === 'boolean')
out[field.slug] = val
continue
}
if (val !== undefined && val !== null && val !== '') {
if (Array.isArray(val) && val.length === 0) continue
out[field.slug] = val
}
}
return Object.keys(out).length ? out : undefined
}
function isPersonalFieldKey(key: string): key is typeof personalFieldKeys[number] {
return (personalFieldKeys as readonly string[]).includes(key)
}
function applyServerValidationErrors(serverErrors: Record<string, string[]>) {
fieldErrors.value = {}
for (const [key, msgs] of Object.entries(serverErrors)) {
const m = /^field_values\.(.+)$/.exec(key)
if (m?.[1]) {
fieldErrors.value[m[1]] = msgs[0] ?? 'Ongeldige waarde.'
continue
}
if (key === 'password') {
passwordServerError.value = msgs[0] ?? 'Ongeldige waarde.'
continue
}
if (key === 'password_confirmation') {
passwordConfirmationServerError.value = msgs[0] ?? 'Ongeldige waarde.'
continue
}
if (isPersonalFieldKey(key))
setFieldError(key, msgs[0] ?? 'Ongeldige waarde.')
}
}
function isRegistrationStepOneServerErrorKey(key: string): boolean {
return isPersonalFieldKey(key) || key === 'password' || key === 'password_confirmation'
}
async function onSubmit() {
submitError.value = null
fieldErrors.value = {}
passwordServerError.value = ''
passwordConfirmationServerError.value = ''
const rPersonal = await Promise.all(personalFieldKeys.map(f => validateField(f)))
if (!rPersonal.every(x => x.valid)) {
currentStep.value = 0
return
}
if (!validatePasswordsForStep()) {
currentStep.value = 0
return
}
if (!validateDynamicFields()) {
currentStep.value = 1
return
}
if (!registrationData.value) return
const field_values = buildFieldValuesPayload()
const section_preferences: SectionPreference[] | undefined
= showSections.value && selectedSectionIds.value.length > 0
? selectedSectionIds.value.map((sectionId, index) => ({
festival_section_id: sectionId,
priority: index + 1,
}))
: undefined
const availabilities: VolunteerAvailability[] = selectedTimeSlotIds.value.map(id => ({
time_slot_id: id,
preference_level: timeSlotPreferences.value[id] ?? 3,
}))
const payload: VolunteerRegistrationForm = {
first_name: firstName.value ?? '',
last_name: lastName.value ?? '',
date_of_birth: dateOfBirth.value ?? '',
email: email.value ?? '',
phone: phone.value ?? '',
availabilities,
}
if (!authStore.isAuthenticated) {
payload.password = password.value
if (emailExists.value === false)
payload.password_confirmation = passwordConfirmation.value
}
if (field_values) payload.field_values = field_values
if (section_preferences?.length) payload.section_preferences = section_preferences
try {
await submitRegistration({
eventId: registrationData.value.event.id,
form: payload,
})
router.push({
path: '/register/success',
query: {
event: registrationData.value.event.name,
banner: registrationData.value.event.registration_banner_url ?? '',
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) {
applyServerValidationErrors(serverErrors)
const keys = Object.keys(serverErrors)
if (keys.some(k => isRegistrationStepOneServerErrorKey(k))) {
currentStep.value = 0
return
}
if (keys.some(k => k.startsWith('field_values.'))) {
currentStep.value = 1
return
}
if (keys.some(k => k.startsWith('section_preferences'))) {
if (sectionsStepIndex.value !== -1) currentStep.value = sectionsStepIndex.value
return
}
}
submitError.value = 'Er zijn validatiefouten gevonden. Controleer je invoer.'
}
else {
submitError.value = 'Er is een fout opgetreden. Probeer het opnieuw.'
}
}
}
</script>
<template>
<!-- Loading -->
<div
v-if="isLoading"
class="d-flex align-center justify-center"
style="min-block-size: 100dvh;"
>
<VCard
flat
:max-width="500"
class="pa-12 text-center"
>
<VProgressCircular
indeterminate
color="primary"
size="48"
class="mb-4"
/>
<p class="text-body-1 text-medium-emphasis mb-0">
Registratieformulier laden...
</p>
</VCard>
</div>
<!-- Error / Not available -->
<div
v-else-if="isError || !registrationData"
class="d-flex align-center justify-center"
style="min-block-size: 100dvh;"
>
<VCard
flat
:max-width="500"
class="text-center pa-8"
>
<VIcon
icon="tabler-calendar-off"
size="64"
color="warning"
class="mb-4"
/>
<h4 class="text-h5 mb-2">
Niet beschikbaar
</h4>
<p class="text-body-1 text-medium-emphasis mb-6">
Dit evenement accepteert momenteel geen aanmeldingen.
</p>
<VBtn
to="/"
variant="outlined"
>
Terug naar startpagina
</VBtn>
</VCard>
</div>
<!-- Registration page -->
<div v-else>
<!-- Hero banner -->
<div
v-if="registrationData.event.registration_banner_url"
class="registration-banner"
>
<VImg
:src="registrationData.event.registration_banner_url"
height="220"
cover
gradient="to bottom, rgba(0,0,0,0.1), rgba(0,0,0,0.55)"
>
<div class="d-flex flex-column align-center justify-end fill-height pa-6 text-white">
<img
v-if="registrationData.event.registration_logo_url"
:src="registrationData.event.registration_logo_url"
height="48"
class="mb-2 rounded"
>
<h2 class="text-h4 font-weight-bold text-center">
{{ registrationData.event.name }}
</h2>
<p class="text-subtitle-1 mb-0">
{{ formattedDates }}
</p>
</div>
</VImg>
</div>
<!-- Fallback header when no banner -->
<div
v-else
class="d-flex flex-column align-center pa-8"
style="background: rgb(var(--v-theme-primary));"
>
<img
v-if="registrationData.event.registration_logo_url"
:src="registrationData.event.registration_logo_url"
height="48"
class="mb-2 rounded"
>
<h2 class="text-h4 font-weight-bold text-white text-center">
{{ registrationData.event.name }}
</h2>
<p
class="text-subtitle-1 mb-0"
style="color: rgba(255,255,255,0.7);"
>
{{ formattedDates }}
</p>
</div>
<!-- Content container -->
<VContainer
class="registration-container"
:class="{ 'mt-n8': registrationData.event.registration_banner_url }"
>
<!-- Welcome text -->
<VCard
v-if="registrationData.event.registration_welcome_text"
class="mb-4 pa-5"
variant="flat"
>
<p class="text-body-1 mb-0">
{{ registrationData.event.registration_welcome_text }}
</p>
</VCard>
<!-- Auth info / Login hint -->
<VAlert
v-if="authStore.isAuthenticated"
type="info"
variant="tonal"
class="mb-4"
>
Je bent ingelogd als {{ authStore.user?.full_name }}. Je gegevens zijn automatisch ingevuld.
</VAlert>
<div
v-else
class="text-body-2 text-medium-emphasis mb-4"
>
Al een account?
<RouterLink
to="/login"
class="text-primary font-weight-medium"
>
Log in
</RouterLink>
om je gegevens automatisch in te vullen.
</div>
<!-- Submit error -->
<VAlert
v-if="submitError"
type="error"
variant="tonal"
class="mb-4"
closable
@click:close="submitError = null"
>
{{ submitError }}
</VAlert>
<!-- Wizard -->
<VRow no-gutters>
<!-- Desktop: vertical step navigation -->
<VCol
v-if="mdAndUp"
cols="12"
md="4"
>
<div class="pa-5 pt-8">
<div
class="d-flex flex-column"
style="gap: 28px;"
>
<div
v-for="(step, index) in steps"
:key="index"
class="d-flex align-center ga-4"
:class="{ 'cursor-pointer': index < currentStep }"
@click="index < currentStep && goToStep(index)"
>
<!-- Number square -->
<div
class="d-flex align-center justify-center flex-shrink-0"
:style="{
width: '50px',
height: '50px',
borderRadius: '10px',
fontSize: '18px',
fontWeight: 700,
backgroundColor: index === currentStep
? 'rgb(var(--v-theme-primary))'
: index < currentStep
? 'rgba(var(--v-theme-primary), 0.16)'
: 'rgba(var(--v-theme-on-surface), 0.08)',
color: index === currentStep
? 'white'
: index < currentStep
? 'rgb(var(--v-theme-primary))'
: 'rgba(var(--v-theme-on-surface), 0.4)',
transition: 'all 0.2s ease',
}"
>
<VIcon
v-if="index < currentStep"
size="22"
>
tabler-check
</VIcon>
<span v-else>{{ index + 1 }}</span>
</div>
<!-- Step text -->
<div>
<div
:class="{
'text-body-1 font-weight-bold': index === currentStep,
'text-body-1 font-weight-medium': index !== currentStep,
}"
:style="index > currentStep
? 'color: rgba(var(--v-theme-on-surface), 0.4)'
: index < currentStep
? 'color: rgba(var(--v-theme-on-surface), 0.5)'
: ''
"
>
{{ step.title }}
</div>
<div
class="text-caption"
:style="{
color: index === currentStep
? 'rgba(var(--v-theme-on-surface), 0.5)'
: 'rgba(var(--v-theme-on-surface), 0.35)',
}"
>
{{ step.subtitle }}
</div>
</div>
</div>
</div>
</div>
</VCol>
<!-- Mobile: horizontal chips -->
<VCol
v-if="!mdAndUp"
cols="12"
>
<VCard
variant="flat"
rounded="lg"
class="mb-4"
>
<div class="d-flex justify-center flex-wrap ga-2 pa-4">
<VChip
v-for="(step, index) in steps"
:key="index"
:color="index === currentStep ? 'primary' : index < currentStep ? 'success' : 'default'"
:variant="index === currentStep ? 'flat' : 'tonal'"
size="small"
>
<VIcon
v-if="index < currentStep"
start
size="14"
>
tabler-check
</VIcon>
<span>{{ index + 1 }}. {{ step.title }}</span>
</VChip>
</div>
</VCard>
</VCol>
<!-- Step content -->
<VCol
cols="12"
:md="mdAndUp ? 8 : 12"
>
<VCard
variant="flat"
rounded="lg"
class="h-100"
>
<VCardText class="pa-8">
<!-- Step header -->
<div class="mb-6">
<p class="text-caption text-medium-emphasis mb-1">
Deel {{ currentStep + 1 }} van {{ steps.length }}
</p>
<h5 class="text-h5 mb-1">
{{ steps[currentStep].title }}
</h5>
<p class="text-body-2 text-medium-emphasis mb-0">
{{ steps[currentStep].subtitle }}
</p>
</div>
<!-- Step 0: Over jou + contact (Vuexy: vertical fields, form separator, contact block) -->
<div v-show="currentStep === 0">
<VRow>
<VCol
cols="12"
md="6"
>
<label
class="text-body-2 d-block mb-1 text-high-emphasis"
for="volunteer-reg-first-name"
>
Voornaam <span class="text-error">*</span>
</label>
<VTextField
id="volunteer-reg-first-name"
v-model="firstName"
variant="outlined"
placeholder="Je voornaam"
:error-messages="errors.first_name"
:disabled="authStore.isAuthenticated"
density="comfortable"
hide-details="auto"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<label
class="text-body-2 d-block mb-1 text-high-emphasis"
for="volunteer-reg-last-name"
>
Achternaam <span class="text-error">*</span>
</label>
<VTextField
id="volunteer-reg-last-name"
v-model="lastName"
variant="outlined"
placeholder="Je achternaam"
:error-messages="errors.last_name"
:disabled="authStore.isAuthenticated"
density="comfortable"
hide-details="auto"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<label
class="text-body-2 d-block mb-1 text-high-emphasis"
for="volunteer-reg-dob"
>
Geboortedatum
</label>
<VTextField
id="volunteer-reg-dob"
v-model="dateOfBirth"
class="volunteer-reg-dob-field"
variant="outlined"
type="date"
:error-messages="errors.date_of_birth"
density="comfortable"
hide-details="auto"
>
<template #prepend-inner>
<VIcon
icon="tabler-calendar"
size="20"
/>
</template>
</VTextField>
</VCol>
</VRow>
<hr class="my-6 mx-n6">
<h6 class="text-h6 mb-2">
Contactgegevens
</h6>
<p class="text-body-2 text-medium-emphasis mb-6">
Vul deze gegevens zorgvuldig in: we gebruiken ze om alle informatie over het evenement naar je te versturen.
Je e-mailadres is ook je gebruikersnaam voor Crewli, het systeem waar je straks alle informatie terugvindt.
</p>
<VRow>
<VCol cols="12">
<label
class="text-body-2 d-block mb-1 text-high-emphasis"
for="volunteer-reg-email"
>
E-mailadres <span class="text-error">*</span>
</label>
<VTextField
id="volunteer-reg-email"
v-model="email"
variant="outlined"
type="email"
placeholder="je@email.nl"
prepend-inner-icon="tabler-mail"
:error-messages="errors.email"
:disabled="authStore.isAuthenticated"
density="comfortable"
hide-details="auto"
@blur="onEmailBlur"
>
<template
v-if="!authStore.isAuthenticated && emailChecking"
#append-inner
>
<VProgressCircular
indeterminate
color="primary"
size="20"
width="2"
/>
</template>
</VTextField>
<p
v-if="!authStore.isAuthenticated && emailExists === true"
class="text-body-2 text-success mt-2 mb-0"
>
We herkennen dit emailadres! Vul je wachtwoord in om verder te gaan.
</p>
<p
v-else-if="!authStore.isAuthenticated && emailExists === false"
class="text-caption text-medium-emphasis mt-2 mb-0"
>
Je maakt een nieuw account aan
</p>
</VCol>
<VCol cols="12">
<label
class="text-body-2 d-block mb-1 text-high-emphasis"
for="volunteer-reg-phone"
>
Telefoonnummer
</label>
<VTextField
id="volunteer-reg-phone"
v-model="phone"
variant="outlined"
placeholder="06 12345678"
prepend-inner-icon="tabler-phone"
:error-messages="errors.phone"
density="comfortable"
hide-details="auto"
/>
</VCol>
</VRow>
<template v-if="!authStore.isAuthenticated && emailExists !== null">
<VRow v-if="emailExists === false">
<VCol cols="12">
<label
class="text-body-2 d-block mb-1 text-high-emphasis"
for="volunteer-reg-password"
>
Wachtwoord <span class="text-error">*</span>
</label>
<VTextField
id="volunteer-reg-password"
v-model="password"
variant="outlined"
:type="showPassword ? 'text' : 'password'"
density="comfortable"
hide-details="auto"
:append-inner-icon="showPassword ? 'mdi-eye-off' : 'mdi-eye'"
:rules="[(v: string) => !!v || 'Verplicht', (v: string) => v.length >= 8 || 'Minimaal 8 tekens']"
:error-messages="passwordServerError || passwordClientError ? [passwordServerError || passwordClientError] : undefined"
@click:append-inner="showPassword = !showPassword"
/>
</VCol>
<VCol cols="12">
<label
class="text-body-2 d-block mb-1 text-high-emphasis"
for="volunteer-reg-password-confirm"
>
Bevestig wachtwoord <span class="text-error">*</span>
</label>
<VTextField
id="volunteer-reg-password-confirm"
v-model="passwordConfirmation"
variant="outlined"
:type="showPasswordConfirmation ? 'text' : 'password'"
density="comfortable"
hide-details="auto"
:append-inner-icon="showPasswordConfirmation ? 'mdi-eye-off' : 'mdi-eye'"
:rules="[(v: string) => v === password || 'Wachtwoorden komen niet overeen']"
:error-messages="passwordConfirmationServerError || passwordConfirmationClientError ? [passwordConfirmationServerError || passwordConfirmationClientError] : undefined"
@click:append-inner="showPasswordConfirmation = !showPasswordConfirmation"
/>
</VCol>
</VRow>
<VRow v-else-if="emailExists === true">
<VCol cols="12">
<label
class="text-body-2 d-block mb-1 text-high-emphasis"
for="volunteer-reg-password-existing"
>
Wachtwoord <span class="text-error">*</span>
</label>
<VTextField
id="volunteer-reg-password-existing"
v-model="password"
variant="outlined"
:type="showPassword ? 'text' : 'password'"
density="comfortable"
hide-details="auto"
hint="Voer je bestaande wachtwoord in"
persistent-hint
:append-inner-icon="showPassword ? 'mdi-eye-off' : 'mdi-eye'"
:rules="[(v: string) => !!v || 'Verplicht']"
:error-messages="passwordServerError || passwordClientError ? [passwordServerError || passwordClientError] : undefined"
@click:append-inner="showPassword = !showPassword"
/>
<RouterLink
to="/wachtwoord-vergeten"
class="text-caption d-inline-block mt-1"
>
Wachtwoord vergeten?
</RouterLink>
</VCol>
</VRow>
</template>
</div>
<!-- Step 1: Extra informatie (dynamic registration_fields) -->
<div v-show="currentStep === 1">
<template
v-for="(group, gi) in groupedRegistrationFields"
:key="gi"
>
<div
v-if="group.section"
class="text-subtitle-2 text-medium-emphasis mt-4 mb-2"
>
{{ group.section }}
</div>
<VRow>
<VCol
v-for="field in group.fields"
:key="field.id"
cols="12"
:md="field.field_type === 'textarea' || field.field_type === 'checkbox' ? '12' : '6'"
>
<div :id="`reg-field-${field.slug}`">
<VTextField
v-if="field.field_type === 'text'"
v-model="fieldFormData[field.slug] as string"
variant="outlined"
density="comfortable"
hide-details="auto"
:label="field.label + (field.is_required ? ' *' : '')"
:hint="field.help_text ?? undefined"
persistent-hint
:error-messages="fieldErrors[field.slug]"
/>
<VTextField
v-else-if="field.field_type === 'number'"
:model-value="numberFieldModel(field.slug)"
variant="outlined"
type="number"
density="comfortable"
hide-details="auto"
:label="field.label + (field.is_required ? ' *' : '')"
:hint="field.help_text ?? undefined"
persistent-hint
:error-messages="fieldErrors[field.slug]"
@update:model-value="(v: string | number | null) => onNumberFieldInput(field.slug, String(v ?? ''))"
/>
<VTextarea
v-else-if="field.field_type === 'textarea'"
v-model="fieldFormData[field.slug] as string"
variant="outlined"
rows="3"
auto-grow
density="comfortable"
hide-details="auto"
:label="field.label + (field.is_required ? ' *' : '')"
:hint="field.help_text ?? undefined"
persistent-hint
:error-messages="fieldErrors[field.slug]"
/>
<VSelect
v-else-if="field.field_type === 'select'"
v-model="fieldFormData[field.slug] as string"
variant="outlined"
density="comfortable"
hide-details="auto"
:items="field.options ?? []"
:label="field.label + (field.is_required ? ' *' : '')"
:hint="field.help_text ?? undefined"
persistent-hint
:error-messages="fieldErrors[field.slug]"
:clearable="!field.is_required"
/>
<VSelect
v-else-if="field.field_type === 'multiselect'"
v-model="fieldFormData[field.slug] as string[]"
variant="outlined"
density="comfortable"
hide-details="auto"
multiple
chips
closable-chips
:items="field.options ?? []"
:label="field.label + (field.is_required ? ' *' : '')"
:hint="field.help_text ?? undefined"
persistent-hint
:error-messages="fieldErrors[field.slug]"
/>
<div v-else-if="field.field_type === 'checkbox'">
<div class="text-body-2 d-block mb-1 text-high-emphasis">
{{ field.label }}<span v-if="field.is_required" class="text-error"> *</span>
</div>
<p
v-if="field.help_text"
class="text-caption text-medium-emphasis mb-2"
>
{{ field.help_text }}
</p>
<VCheckbox
v-for="opt in (field.options ?? [])"
:key="opt"
:model-value="isCheckboxChecked(field.slug, opt)"
density="comfortable"
hide-details
:label="opt"
@update:model-value="(v: boolean | null) => toggleCheckboxOption(field.slug, opt, v)"
/>
<div
v-if="fieldErrors[field.slug]"
class="text-caption text-error"
>
{{ fieldErrors[field.slug] }}
</div>
</div>
<div v-else-if="field.field_type === 'radio'">
<div class="text-body-2 d-block mb-1 text-high-emphasis">
{{ field.label }}<span v-if="field.is_required" class="text-error"> *</span>
</div>
<p
v-if="field.help_text"
class="text-caption text-medium-emphasis mb-2"
>
{{ field.help_text }}
</p>
<VRadioGroup
v-model="fieldFormData[field.slug] as string"
density="comfortable"
hide-details="auto"
:error-messages="fieldErrors[field.slug]"
>
<VRadio
v-for="opt in (field.options ?? [])"
:key="opt"
:label="opt"
:value="opt"
density="comfortable"
hide-details
/>
</VRadioGroup>
</div>
<VSwitch
v-else-if="field.field_type === 'boolean'"
v-model="fieldFormData[field.slug] as boolean"
inset
color="primary"
density="comfortable"
hide-details="auto"
:label="field.label + (field.is_required ? ' *' : '')"
:hint="field.help_text ?? undefined"
persistent-hint
:error-messages="fieldErrors[field.slug]"
/>
<VSelect
v-else-if="field.field_type === 'tag_picker'"
v-model="fieldFormData[field.slug] as string[]"
variant="outlined"
density="comfortable"
hide-details="auto"
multiple
chips
closable-chips
:items="field.available_tags ?? []"
item-title="name"
item-value="id"
:label="field.label + (field.is_required ? ' *' : '')"
:hint="field.help_text ?? undefined"
persistent-hint
:error-messages="fieldErrors[field.slug]"
/>
</div>
</VCol>
</VRow>
</template>
<p
v-if="registrationFieldsList.length === 0"
class="text-body-2 text-medium-emphasis mb-0"
>
Geen extra vragen voor dit evenement.
</p>
</div>
<!-- Secties -->
<div v-show="currentStepKind === 'sections'">
<template
v-for="(sections, category) in sectionsByCategory"
:key="category"
>
<div class="text-subtitle-2 text-medium-emphasis mt-4 mb-2">
{{ category }}
</div>
<VRow dense>
<VCol
v-for="section in sections"
:key="section.id"
cols="12"
sm="6"
>
<VCard
:variant="isSelected(section.id) ? 'flat' : 'outlined'"
:color="isSelected(section.id) ? 'primary' : undefined"
class="cursor-pointer"
:disabled="!isSelected(section.id) && selectedCount >= 5"
@click="toggleSection(section.id)"
>
<VCardText class="d-flex align-center ga-3 pa-3">
<VCheckboxBtn
:model-value="isSelected(section.id)"
readonly
density="compact"
hide-details
/>
<VIcon
v-if="section.icon"
size="20"
:icon="section.icon"
/>
<div class="flex-grow-1">
<div class="text-body-1 font-weight-medium">
{{ section.name }}
</div>
<div
v-if="section.registration_description"
class="text-body-2 text-medium-emphasis"
>
{{ section.registration_description }}
</div>
</div>
<VChip
v-if="isSelected(section.id)"
size="x-small"
color="primary"
variant="elevated"
>
#{{ getSelectionPriority(section.id) }}
</VChip>
</VCardText>
</VCard>
</VCol>
</VRow>
</template>
<VAlert
v-if="!Object.keys(sectionsByCategory).length"
type="info"
variant="tonal"
class="mt-4"
>
Er zijn nog geen werkgebieden geconfigureerd voor dit evenement.
</VAlert>
<p
v-if="selectedCount > 0"
class="text-body-2 text-medium-emphasis mt-4"
>
{{ selectedCount }} van 5 onderdelen geselecteerd
</p>
</div>
<!-- Beschikbaarheid -->
<div v-show="currentStepKind === 'availability'">
<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>
<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"
class="registration-availability-list"
>
<VListItem
v-for="slot in slots"
:key="slot.id"
@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) }} &middot; {{ 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>
<!-- Navigation -->
<VDivider class="my-6" />
<div class="d-flex justify-space-between">
<VBtn
v-if="currentStep > 0"
variant="outlined"
color="secondary"
@click="prevStep"
>
<VIcon
icon="tabler-arrow-left"
start
/>
Vorige
</VBtn>
<div v-else />
<VBtn
v-if="currentStep < steps.length - 1"
color="primary"
@click="nextStep"
>
Volgende
<VIcon
icon="tabler-arrow-right"
end
/>
</VBtn>
<VBtn
v-else
color="success"
:loading="isSubmitting"
@click="onSubmit"
>
<VIcon
icon="tabler-send"
start
/>
Aanmelding versturen
</VBtn>
</div>
</VCardText>
</VCard>
</VCol>
</VRow>
<!-- Footer -->
<div class="text-center pa-4 text-caption text-medium-emphasis">
Powered by Crewli
</div>
</VContainer>
</div>
</template>
<style scoped>
.registration-container {
position: relative;
z-index: 1;
max-inline-size: 1000px;
}
/*
Native date inputs have a large intrinsic min-width; without min-width: 0 the
grid cell grows and the prepend-inner icon no longer sits inside the outline
next to the value (Vuetify v-field grid: prepend-inner | field).
*/
.volunteer-reg-dob-field :deep(.v-field__field) {
min-inline-size: 0;
}
.volunteer-reg-dob-field :deep(input.v-field__input[type="date"]) {
flex: 1 1 auto;
max-inline-size: 100%;
min-inline-size: 0;
}
/*
Vuetifys .v-list uses overflow: auto, which shows scrollbars on this step when
list rows are slightly wider than the card (e.g. checkbox + text + rating).
*/
.registration-availability-list {
overflow: visible !important;
}
</style>