Files
crewli/apps/portal/src/pages/register/[eventSlug].vue
bert.hausmans 6a8d21a5b6 feat: registration field polish, multi-category tags, file uploads, Partner icon
- Restructure field editor dialog: move Options section to bottom with
  divider and subheader, fix delete button with flex layout
- Change tag_category (single string) to tag_categories (JSON array)
  supporting multiple category selection in tag picker fields
- Portal tag picker now groups tags by category with subheaders
- Add generic file upload endpoint (FileUploadService + UploadController)
- Replace email branding logo URL text field with ImageUploadField
- Update Partner crowd type default icon to tabler-affiliate
- Apply changes consistently to both field and template dialogs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 18:03:49 +02:00

1538 lines
51 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 { usePortalStore } from '@/stores/usePortalStore'
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: 'portal',
requiresAuth: false,
navMode: 'platform',
},
})
const route = useRoute('volunteer-register')
const router = useRouter()
const authStore = useAuthStore()
const portalStore = usePortalStore()
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>>({})
// Email existence check (privacy-safe — only shows boolean)
const emailExists = ref<boolean | null>(null)
const emailChecking = ref(false)
const lastCheckedEmail = ref('')
let checkEmailRequestSeq = 0
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
}
})
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 ?? [])
function defaultFieldValue(type: RegistrationFieldType): unknown {
switch (type) {
case 'multiselect':
case 'checkbox':
case 'tag_picker':
return []
case 'boolean':
return false
case 'number':
case 'heading':
return null
default:
return ''
}
}
watch(registrationFieldsList, fields => {
for (const f of fields) {
if (f.field_type === 'heading') continue
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 groupedTagItems(tags: Array<{ id: string; name: string; category: string | null }>) {
const items: Array<{ type?: string; title: string; value?: string }> = []
let lastCategory: string | null = null
for (const tag of tags) {
if (tag.category !== lastCategory) {
items.push({ type: 'subheader', title: tag.category ?? 'Overig' })
lastCategory = tag.category
}
items.push({ title: tag.name, value: tag.id })
}
return items
}
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.field_type === 'heading' || !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)))
return results.every(r => r.valid)
}
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) {
if (field.field_type === 'heading') continue
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 (isPersonalFieldKey(key))
setFieldError(key, msgs[0] ?? 'Ongeldige waarde.')
}
}
async function onSubmit() {
submitError.value = null
fieldErrors.value = {}
const rPersonal = await Promise.all(personalFieldKeys.map(f => validateField(f)))
if (!rPersonal.every(x => x.valid)) {
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 (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,
})
const ev = registrationData.value.event
portalStore.savePendingEventFromRegistration({
event_id: ev.id,
event_name: ev.name,
organisation_name: '',
organisation_id: ev.organisation_id,
person_status: 'pending',
start_date: ev.start_date,
end_date: ev.end_date,
})
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 => isPersonalFieldKey(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 -->
<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"
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"
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.
</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>
</VCol>
<!-- Existing account notice -->
<VCol
v-if="!authStore.isAuthenticated && emailExists === true"
cols="12"
>
<VAlert
type="info"
variant="tonal"
>
<template #title>
Er bestaat al een account met dit e-mailadres
</template>
<template #text>
<p class="mb-2">
Als je al eerder bij een evenement betrokken bent geweest,
kun je inloggen om je registratie automatisch te koppelen
aan je bestaande account.
</p>
<VBtn
size="small"
variant="tonal"
color="primary"
:to="`/login?to=/register/${eventSlug}`"
>
Inloggen en registreren
</VBtn>
<p class="text-caption text-medium-emphasis mt-2 mb-0">
Je kunt ook gewoon doorgaan met registreren we koppelen
je registratie later aan je account.
</p>
</template>
</VAlert>
</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>
</div>
<!-- Step 1: Extra informatie (dynamic registration_fields) -->
<div v-show="currentStep === 1">
<VRow>
<template
v-for="(field, fieldIndex) in registrationFieldsList"
:key="field.id"
>
<!-- HEADING field: full-width section header -->
<VCol
v-if="field.field_type === 'heading'"
cols="12"
:class="fieldIndex === 0 ? '' : 'mt-4'"
>
<div class="text-subtitle-1 font-weight-medium mb-1">
{{ field.label }}
</div>
<div
v-if="field.help_text"
class="text-body-2 text-medium-emphasis mb-3"
>
{{ field.help_text }}
</div>
<VDivider class="mb-2" />
</VCol>
<!-- Regular input field -->
<VCol
v-else
cols="12"
:md="field.display_width === 'half' ? 6 : 12"
>
<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.normalized_options ?? []"
item-title="label"
item-value="label"
:label="field.label + (field.is_required ? ' *' : '')"
:hint="field.help_text ?? undefined"
persistent-hint
:error-messages="fieldErrors[field.slug]"
:clearable="!field.is_required"
>
<template #item="{ props: itemProps, item }">
<VListItem v-bind="itemProps">
<template
v-if="item.raw.description"
#subtitle
>
{{ item.raw.description }}
</template>
</VListItem>
</template>
</VSelect>
<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.normalized_options ?? []"
item-title="label"
item-value="label"
:label="field.label + (field.is_required ? ' *' : '')"
:hint="field.help_text ?? undefined"
persistent-hint
:error-messages="fieldErrors[field.slug]"
>
<template #item="{ props: itemProps, item }">
<VListItem v-bind="itemProps">
<template
v-if="item.raw.description"
#subtitle
>
{{ item.raw.description }}
</template>
</VListItem>
</template>
</VSelect>
<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.normalized_options ?? [])"
:key="opt.label"
:model-value="isCheckboxChecked(field.slug, opt.label)"
density="comfortable"
hide-details
@update:model-value="(v: boolean | null) => toggleCheckboxOption(field.slug, opt.label, v)"
>
<template #label>
<div>
<span class="text-body-1">{{ opt.label }}</span>
<p
v-if="opt.description"
class="text-body-2 text-medium-emphasis mt-1 mb-0"
>
{{ opt.description }}
</p>
</div>
</template>
</VCheckbox>
<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.normalized_options ?? [])"
:key="opt.label"
:value="opt.label"
density="comfortable"
hide-details
>
<template #label>
<div>
<span class="text-body-1">{{ opt.label }}</span>
<p
v-if="opt.description"
class="text-body-2 text-medium-emphasis mt-1 mb-0"
>
{{ opt.description }}
</p>
</div>
</template>
</VRadio>
</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]"
/>
<VAutocomplete
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="groupedTagItems(field.available_tags ?? [])"
item-title="title"
item-value="value"
:label="field.label + (field.is_required ? ' *' : '')"
:hint="field.help_text ?? undefined"
persistent-hint
:error-messages="fieldErrors[field.slug]"
/>
</div>
</VCol>
</template>
</VRow>
<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>
</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;
}
/*
Vuetify's .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>