1631 lines
54 KiB
Vue
1631 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 { 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) }} · {{ 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>
|