Replace the simple inline header with a proper Vuexy-style horizontal navbar featuring left (logo + event switcher), center (conditional menu items based on approval status), and right (avatar dropdown with profile link and logout) sections. Move profile page from /profile to /profiel as a platform-level page with "Mijn evenementen" section, removing the event-scoped status card and remarks field. Registration and success pages now use the portal layout with hideEventMenu meta so they get the navbar when logged in but no event menu items. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1643 lines
54 KiB
Vue
1643 lines
54 KiB
Vue
<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,
|
||
hideEventMenu: true,
|
||
},
|
||
})
|
||
|
||
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>>({})
|
||
|
||
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,
|
||
})
|
||
|
||
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 => 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"
|
||
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.
|
||
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) }} · {{ 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;
|
||
}
|
||
|
||
/*
|
||
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>
|