feat(portal): registration API fields, field_values, conditional steps
Wire registration_fields and event toggles into the existing wizard; replace hardcoded step content with dynamic controls; submit field_values and festival_section_id section_preferences; map 422 field_values.* to inline errors. Made-with: Cursor
This commit is contained in:
@@ -6,6 +6,8 @@ import { useAuthStore } from '@/stores/useAuthStore'
|
|||||||
import { useRegistrationData, useSubmitRegistration } from '@/composables/api/useVolunteerRegistration'
|
import { useRegistrationData, useSubmitRegistration } from '@/composables/api/useVolunteerRegistration'
|
||||||
import { fullRegistrationSchema } from '@/schemas/registrationSchema'
|
import { fullRegistrationSchema } from '@/schemas/registrationSchema'
|
||||||
import type {
|
import type {
|
||||||
|
RegistrationField,
|
||||||
|
RegistrationFieldType,
|
||||||
SectionOption,
|
SectionOption,
|
||||||
SectionPreference,
|
SectionPreference,
|
||||||
TimeSlotOption,
|
TimeSlotOption,
|
||||||
@@ -32,9 +34,10 @@ const { mutateAsync: submitRegistration, isPending: isSubmitting } = useSubmitRe
|
|||||||
|
|
||||||
const currentStep = ref(0)
|
const currentStep = ref(0)
|
||||||
const submitError = ref<string | null>(null)
|
const submitError = ref<string | null>(null)
|
||||||
|
const fieldFormData = ref<Record<string, unknown>>({})
|
||||||
|
const fieldErrors = ref<Record<string, string>>({})
|
||||||
|
|
||||||
// VeeValidate form
|
const { errors, defineField, validateField, setFieldValue, setFieldError } = useForm({
|
||||||
const { errors, defineField, validateField, setFieldValue } = useForm({
|
|
||||||
validationSchema: toTypedSchema(fullRegistrationSchema),
|
validationSchema: toTypedSchema(fullRegistrationSchema),
|
||||||
initialValues: {
|
initialValues: {
|
||||||
first_name: '',
|
first_name: '',
|
||||||
@@ -42,12 +45,6 @@ const { errors, defineField, validateField, setFieldValue } = useForm({
|
|||||||
email: '',
|
email: '',
|
||||||
date_of_birth: '',
|
date_of_birth: '',
|
||||||
phone: '',
|
phone: '',
|
||||||
tshirt_size: '',
|
|
||||||
first_aid: false,
|
|
||||||
allergies: '',
|
|
||||||
driving_licence: false,
|
|
||||||
motivation: '',
|
|
||||||
motivation_other: '',
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -56,12 +53,6 @@ const [lastName] = defineField('last_name')
|
|||||||
const [dateOfBirth] = defineField('date_of_birth')
|
const [dateOfBirth] = defineField('date_of_birth')
|
||||||
const [email] = defineField('email')
|
const [email] = defineField('email')
|
||||||
const [phone] = defineField('phone')
|
const [phone] = defineField('phone')
|
||||||
const [tshirtSize] = defineField('tshirt_size')
|
|
||||||
const [firstAid] = defineField('first_aid')
|
|
||||||
const [allergies] = defineField('allergies')
|
|
||||||
const [drivingLicence] = defineField('driving_licence')
|
|
||||||
const [motivation] = defineField('motivation')
|
|
||||||
const [motivationOther] = defineField('motivation_other')
|
|
||||||
|
|
||||||
// Pre-fill authenticated user data
|
// Pre-fill authenticated user data
|
||||||
watch(() => authStore.user, user => {
|
watch(() => authStore.user, user => {
|
||||||
@@ -72,36 +63,84 @@ watch(() => authStore.user, user => {
|
|||||||
}
|
}
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
// Step 3: Section preferences (by name, not ID)
|
const selectedSectionIds = ref<string[]>([])
|
||||||
const selectedSections = ref<string[]>([])
|
|
||||||
|
|
||||||
// Step 4: Availability
|
|
||||||
const selectedTimeSlotIds = ref<string[]>([])
|
const selectedTimeSlotIds = ref<string[]>([])
|
||||||
const timeSlotPreferences = ref<Record<string, number>>({})
|
const timeSlotPreferences = ref<Record<string, number>>({})
|
||||||
|
|
||||||
// Steps definition
|
const showSections = computed(() => Boolean(registrationData.value?.event.registration_show_section_preferences))
|
||||||
const steps = [
|
const showAvailability = computed(() => Boolean(registrationData.value?.event.registration_show_availability))
|
||||||
{ title: 'Over jou', subtitle: 'Vul je persoonlijke gegevens in' },
|
|
||||||
{ title: 'Meer over jou', subtitle: 'Praktische informatie' },
|
|
||||||
{ title: 'Motivatie', subtitle: 'Waarom wil je meehelpen?' },
|
|
||||||
{ title: 'Secties', subtitle: 'Waar wil je het liefst werken?' },
|
|
||||||
{ title: 'Beschikbaarheid', subtitle: 'Wanneer kun je helpen?' },
|
|
||||||
]
|
|
||||||
|
|
||||||
// Constants
|
const steps = computed(() => {
|
||||||
const tshirtSizeItems = [
|
const list = [
|
||||||
{ title: 'Geen voorkeur', value: '' },
|
{ title: 'Over jou', subtitle: 'Vul je persoonlijke gegevens in' },
|
||||||
...['XS', 'S', 'M', 'L', 'XL', 'XXL', 'XXXL'].map(s => ({ title: s, value: s })),
|
{ 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?' })
|
||||||
|
|
||||||
const motivationItems = [
|
return list
|
||||||
{ title: 'Gratis festivalpas', value: 'Gratis festivalpas' },
|
})
|
||||||
{ title: 'Ervaring opdoen', value: 'Ervaring opdoen' },
|
|
||||||
{ title: 'Vrienden helpen', value: 'Vrienden helpen' },
|
const sectionsStepIndex = computed(() => (showSections.value ? 2 : -1))
|
||||||
{ title: 'CV opbouwen', value: 'CV opbouwen' },
|
const availabilityStepIndex = computed(() => {
|
||||||
{ title: 'Ik ben gevraagd', value: 'Ik ben gevraagd' },
|
if (!showAvailability.value) return -1
|
||||||
{ title: 'Anders', value: 'Anders' },
|
|
||||||
]
|
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
|
// Section helpers
|
||||||
const sectionsByCategory = computed(() => {
|
const sectionsByCategory = computed(() => {
|
||||||
@@ -116,15 +155,15 @@ const sectionsByCategory = computed(() => {
|
|||||||
}, {} as Record<string, SectionOption[]>)
|
}, {} as Record<string, SectionOption[]>)
|
||||||
})
|
})
|
||||||
|
|
||||||
function isSelected(sectionName: string) {
|
function isSelected(sectionId: string) {
|
||||||
return selectedSections.value.includes(sectionName)
|
return selectedSectionIds.value.includes(sectionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSelectionPriority(sectionName: string) {
|
function getSelectionPriority(sectionId: string) {
|
||||||
return selectedSections.value.indexOf(sectionName) + 1
|
return selectedSectionIds.value.indexOf(sectionId) + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedCount = computed(() => selectedSections.value.length)
|
const selectedCount = computed(() => selectedSectionIds.value.length)
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
const timeSlotsByDate = computed(() => {
|
const timeSlotsByDate = computed(() => {
|
||||||
@@ -158,27 +197,108 @@ const formattedDates = computed(() => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Step field mapping for validation (0-based)
|
const personalFieldKeys = ['first_name', 'last_name', 'date_of_birth', 'email', 'phone'] as const
|
||||||
type FormField = 'first_name' | 'last_name' | 'date_of_birth' | 'email' | 'phone' | 'tshirt_size' | 'first_aid' | 'allergies' | 'driving_licence' | 'motivation' | 'motivation_other'
|
|
||||||
|
|
||||||
const stepFields: Record<number, FormField[]> = {
|
function isMultiValueType(t: RegistrationFieldType): boolean {
|
||||||
0: ['first_name', 'last_name', 'date_of_birth', 'email', 'phone'],
|
return t === 'multiselect' || t === 'checkbox' || t === 'tag_picker'
|
||||||
1: ['tshirt_size', 'first_aid', 'allergies', 'driving_licence'],
|
|
||||||
2: ['motivation', 'motivation_other'],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Navigation
|
function isEmptyFieldValue(value: unknown, type: RegistrationFieldType): boolean {
|
||||||
async function validateCurrentStep(): Promise<boolean> {
|
if (value === undefined || value === null) return true
|
||||||
const fields = stepFields[currentStep.value]
|
if (value === '') return true
|
||||||
if (!fields) return true
|
if (type === 'number' && (typeof value !== 'number' || Number.isNaN(value))) return true
|
||||||
const results = await Promise.all(fields.map(f => validateField(f)))
|
if (isMultiValueType(type)) {
|
||||||
|
if (!Array.isArray(value)) return true
|
||||||
|
|
||||||
return results.every(r => r.valid)
|
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)))
|
||||||
|
|
||||||
|
return results.every(r => r.valid)
|
||||||
|
}
|
||||||
|
if (k === 'dynamic')
|
||||||
|
return validateDynamicFields()
|
||||||
|
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function nextStep() {
|
async function nextStep() {
|
||||||
if (await validateCurrentStep()) {
|
if (await validateCurrentStep()) {
|
||||||
if (currentStep.value < 4) currentStep.value++
|
if (currentStep.value < steps.value.length - 1) currentStep.value++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,14 +312,13 @@ function goToStep(index: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Section toggle
|
function toggleSection(sectionId: string) {
|
||||||
function toggleSection(sectionName: string) {
|
const idx = selectedSectionIds.value.indexOf(sectionId)
|
||||||
const idx = selectedSections.value.indexOf(sectionName)
|
|
||||||
if (idx !== -1) {
|
if (idx !== -1) {
|
||||||
selectedSections.value.splice(idx, 1)
|
selectedSectionIds.value.splice(idx, 1)
|
||||||
}
|
}
|
||||||
else if (selectedSections.value.length < 5) {
|
else if (selectedSectionIds.value.length < 5) {
|
||||||
selectedSections.value.push(sectionName)
|
selectedSectionIds.value.push(sectionId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,29 +359,69 @@ function formatTimeRange(start: string, end: string): string {
|
|||||||
return `${start.slice(0, 5)} – ${end.slice(0, 5)}`
|
return `${start.slice(0, 5)} – ${end.slice(0, 5)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Submit
|
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 (isPersonalFieldKey(key))
|
||||||
|
setFieldError(key, msgs[0] ?? 'Ongeldige waarde.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function onSubmit() {
|
async function onSubmit() {
|
||||||
submitError.value = null
|
submitError.value = null
|
||||||
|
fieldErrors.value = {}
|
||||||
|
|
||||||
// Validate steps 0-2
|
const rPersonal = await Promise.all(personalFieldKeys.map(f => validateField(f)))
|
||||||
for (let step = 0; step <= 2; step++) {
|
if (!rPersonal.every(x => x.valid)) {
|
||||||
const fields = stepFields[step]
|
currentStep.value = 0
|
||||||
if (!fields) continue
|
|
||||||
const results = await Promise.all(fields.map(f => validateField(f)))
|
|
||||||
|
|
||||||
if (!results.every(r => r.valid)) {
|
return
|
||||||
currentStep.value = step
|
}
|
||||||
|
if (!validateDynamicFields()) {
|
||||||
|
currentStep.value = 1
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!registrationData.value) return
|
if (!registrationData.value) return
|
||||||
|
|
||||||
const sectionPreferences: SectionPreference[] = selectedSections.value.map((sectionName, index) => ({
|
const field_values = buildFieldValuesPayload()
|
||||||
section_name: sectionName,
|
|
||||||
priority: index + 1,
|
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 => ({
|
const availabilities: VolunteerAvailability[] = selectedTimeSlotIds.value.map(id => ({
|
||||||
time_slot_id: id,
|
time_slot_id: id,
|
||||||
@@ -275,16 +434,12 @@ async function onSubmit() {
|
|||||||
date_of_birth: dateOfBirth.value ?? '',
|
date_of_birth: dateOfBirth.value ?? '',
|
||||||
email: email.value ?? '',
|
email: email.value ?? '',
|
||||||
phone: phone.value ?? '',
|
phone: phone.value ?? '',
|
||||||
tshirt_size: tshirtSize.value ?? '',
|
|
||||||
first_aid: firstAid.value ?? false,
|
|
||||||
allergies: allergies.value ?? '',
|
|
||||||
driving_licence: drivingLicence.value ?? false,
|
|
||||||
motivation: motivation.value ?? '',
|
|
||||||
motivation_other: motivationOther.value ?? '',
|
|
||||||
section_preferences: sectionPreferences,
|
|
||||||
availabilities,
|
availabilities,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (field_values) payload.field_values = field_values
|
||||||
|
if (section_preferences?.length) payload.section_preferences = section_preferences
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await submitRegistration({
|
await submitRegistration({
|
||||||
eventId: registrationData.value.event.id,
|
eventId: registrationData.value.event.id,
|
||||||
@@ -307,14 +462,22 @@ async function onSubmit() {
|
|||||||
const serverErrors = axiosError.response.data?.errors
|
const serverErrors = axiosError.response.data?.errors
|
||||||
|
|
||||||
if (serverErrors) {
|
if (serverErrors) {
|
||||||
for (const field of Object.keys(serverErrors)) {
|
applyServerValidationErrors(serverErrors)
|
||||||
for (const [step, fields] of Object.entries(stepFields)) {
|
const keys = Object.keys(serverErrors)
|
||||||
if (fields.includes(field as FormField)) {
|
if (keys.some(k => isPersonalFieldKey(k))) {
|
||||||
currentStep.value = Number(step)
|
currentStep.value = 0
|
||||||
|
|
||||||
return
|
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.'
|
submitError.value = 'Er zijn validatiefouten gevonden. Controleer je invoer.'
|
||||||
@@ -614,6 +777,9 @@ async function onSubmit() {
|
|||||||
<VCardText class="pa-8">
|
<VCardText class="pa-8">
|
||||||
<!-- Step header -->
|
<!-- Step header -->
|
||||||
<div class="mb-6">
|
<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">
|
<h5 class="text-h5 mb-1">
|
||||||
{{ steps[currentStep].title }}
|
{{ steps[currentStep].title }}
|
||||||
</h5>
|
</h5>
|
||||||
@@ -752,133 +918,196 @@ async function onSubmit() {
|
|||||||
</VRow>
|
</VRow>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Step 1: Meer over jou -->
|
<!-- Step 1: Extra informatie (dynamic registration_fields) -->
|
||||||
<div v-show="currentStep === 1">
|
<div v-show="currentStep === 1">
|
||||||
<VRow>
|
<template
|
||||||
<VCol
|
v-for="(group, gi) in groupedRegistrationFields"
|
||||||
cols="12"
|
:key="gi"
|
||||||
md="6"
|
>
|
||||||
|
<div
|
||||||
|
v-if="group.section"
|
||||||
|
class="text-subtitle-2 text-medium-emphasis mt-4 mb-2"
|
||||||
>
|
>
|
||||||
<label
|
{{ group.section }}
|
||||||
class="text-body-2 d-block mb-1 text-high-emphasis"
|
</div>
|
||||||
for="volunteer-reg-tshirt"
|
|
||||||
>
|
|
||||||
Shirtmaat
|
|
||||||
</label>
|
|
||||||
<VSelect
|
|
||||||
id="volunteer-reg-tshirt"
|
|
||||||
v-model="tshirtSize"
|
|
||||||
:items="tshirtSizeItems"
|
|
||||||
variant="outlined"
|
|
||||||
placeholder="Selecteer je maat"
|
|
||||||
:error-messages="errors.tshirt_size"
|
|
||||||
density="comfortable"
|
|
||||||
hide-details="auto"
|
|
||||||
/>
|
|
||||||
</VCol>
|
|
||||||
|
|
||||||
<VCol
|
<VRow>
|
||||||
cols="12"
|
|
||||||
md="6"
|
|
||||||
class="d-flex align-end"
|
|
||||||
>
|
|
||||||
<div class="d-flex ga-6 flex-wrap pb-1 w-100">
|
|
||||||
<VSwitch
|
|
||||||
v-model="firstAid"
|
|
||||||
label="EHBO-diploma"
|
|
||||||
color="primary"
|
|
||||||
hide-details
|
|
||||||
/>
|
|
||||||
|
|
||||||
<VSwitch
|
|
||||||
v-model="drivingLicence"
|
|
||||||
label="Rijbewijs B"
|
|
||||||
color="primary"
|
|
||||||
hide-details
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</VCol>
|
|
||||||
|
|
||||||
<VCol cols="12">
|
|
||||||
<label
|
|
||||||
class="text-body-2 d-block mb-1 text-high-emphasis"
|
|
||||||
for="volunteer-reg-allergies"
|
|
||||||
>
|
|
||||||
Allergieën of dieetwensen
|
|
||||||
</label>
|
|
||||||
<VTextarea
|
|
||||||
id="volunteer-reg-allergies"
|
|
||||||
v-model="allergies"
|
|
||||||
variant="outlined"
|
|
||||||
placeholder="Laat ons weten als je allergieën of dieetwensen hebt"
|
|
||||||
:error-messages="errors.allergies"
|
|
||||||
:counter="500"
|
|
||||||
rows="2"
|
|
||||||
auto-grow
|
|
||||||
density="comfortable"
|
|
||||||
hide-details="auto"
|
|
||||||
/>
|
|
||||||
</VCol>
|
|
||||||
|
|
||||||
</VRow>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Step 2: Motivatie -->
|
|
||||||
<div v-show="currentStep === 2">
|
|
||||||
<VRow>
|
|
||||||
<VCol
|
|
||||||
cols="12"
|
|
||||||
md="6"
|
|
||||||
>
|
|
||||||
<label
|
|
||||||
class="text-body-2 d-block mb-1 text-high-emphasis"
|
|
||||||
for="volunteer-reg-motivation"
|
|
||||||
>
|
|
||||||
Waarom wil je vrijwilliger zijn?
|
|
||||||
</label>
|
|
||||||
<VSelect
|
|
||||||
id="volunteer-reg-motivation"
|
|
||||||
v-model="motivation"
|
|
||||||
:items="motivationItems"
|
|
||||||
variant="outlined"
|
|
||||||
placeholder="Selecteer je motivatie"
|
|
||||||
:error-messages="errors.motivation"
|
|
||||||
clearable
|
|
||||||
density="comfortable"
|
|
||||||
hide-details="auto"
|
|
||||||
/>
|
|
||||||
</VCol>
|
|
||||||
|
|
||||||
<VExpandTransition>
|
|
||||||
<VCol
|
<VCol
|
||||||
v-if="motivation"
|
v-for="field in group.fields"
|
||||||
|
:key="field.id"
|
||||||
cols="12"
|
cols="12"
|
||||||
|
:md="field.field_type === 'textarea' || field.field_type === 'checkbox' ? '12' : '6'"
|
||||||
>
|
>
|
||||||
<label
|
<div :id="`reg-field-${field.slug}`">
|
||||||
class="text-body-2 d-block mb-1 text-high-emphasis"
|
<VTextField
|
||||||
for="volunteer-reg-motivation-other"
|
v-if="field.field_type === 'text'"
|
||||||
>
|
v-model="fieldFormData[field.slug] as string"
|
||||||
Toelichting
|
variant="outlined"
|
||||||
</label>
|
density="comfortable"
|
||||||
<VTextarea
|
hide-details="auto"
|
||||||
id="volunteer-reg-motivation-other"
|
:label="field.label + (field.is_required ? ' *' : '')"
|
||||||
v-model="motivationOther"
|
:hint="field.help_text ?? undefined"
|
||||||
variant="outlined"
|
persistent-hint
|
||||||
placeholder="Vertel ons meer over je motivatie..."
|
:error-messages="fieldErrors[field.slug]"
|
||||||
:error-messages="errors.motivation_other"
|
/>
|
||||||
:counter="500"
|
|
||||||
rows="3"
|
<VTextField
|
||||||
auto-grow
|
v-else-if="field.field_type === 'number'"
|
||||||
density="comfortable"
|
:model-value="numberFieldModel(field.slug)"
|
||||||
hide-details="auto"
|
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>
|
</VCol>
|
||||||
</VExpandTransition>
|
</VRow>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Step 3: Secties -->
|
<!-- Secties -->
|
||||||
<div v-show="currentStep === 3">
|
<div v-show="currentStepKind === 'sections'">
|
||||||
<template
|
<template
|
||||||
v-for="(sections, category) in sectionsByCategory"
|
v-for="(sections, category) in sectionsByCategory"
|
||||||
:key="category"
|
:key="category"
|
||||||
@@ -895,15 +1124,15 @@ async function onSubmit() {
|
|||||||
sm="6"
|
sm="6"
|
||||||
>
|
>
|
||||||
<VCard
|
<VCard
|
||||||
:variant="isSelected(section.name) ? 'flat' : 'outlined'"
|
:variant="isSelected(section.id) ? 'flat' : 'outlined'"
|
||||||
:color="isSelected(section.name) ? 'primary' : undefined"
|
:color="isSelected(section.id) ? 'primary' : undefined"
|
||||||
class="cursor-pointer"
|
class="cursor-pointer"
|
||||||
:disabled="!isSelected(section.name) && selectedCount >= 5"
|
:disabled="!isSelected(section.id) && selectedCount >= 5"
|
||||||
@click="toggleSection(section.name)"
|
@click="toggleSection(section.id)"
|
||||||
>
|
>
|
||||||
<VCardText class="d-flex align-center ga-3 pa-3">
|
<VCardText class="d-flex align-center ga-3 pa-3">
|
||||||
<VCheckboxBtn
|
<VCheckboxBtn
|
||||||
:model-value="isSelected(section.name)"
|
:model-value="isSelected(section.id)"
|
||||||
readonly
|
readonly
|
||||||
density="compact"
|
density="compact"
|
||||||
hide-details
|
hide-details
|
||||||
@@ -912,9 +1141,8 @@ async function onSubmit() {
|
|||||||
<VIcon
|
<VIcon
|
||||||
v-if="section.icon"
|
v-if="section.icon"
|
||||||
size="20"
|
size="20"
|
||||||
>
|
:icon="section.icon"
|
||||||
{{ section.icon }}
|
/>
|
||||||
</VIcon>
|
|
||||||
|
|
||||||
<div class="flex-grow-1">
|
<div class="flex-grow-1">
|
||||||
<div class="text-body-1 font-weight-medium">
|
<div class="text-body-1 font-weight-medium">
|
||||||
@@ -929,12 +1157,12 @@ async function onSubmit() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<VChip
|
<VChip
|
||||||
v-if="isSelected(section.name)"
|
v-if="isSelected(section.id)"
|
||||||
size="x-small"
|
size="x-small"
|
||||||
color="primary"
|
color="primary"
|
||||||
variant="elevated"
|
variant="elevated"
|
||||||
>
|
>
|
||||||
#{{ getSelectionPriority(section.name) }}
|
#{{ getSelectionPriority(section.id) }}
|
||||||
</VChip>
|
</VChip>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
</VCard>
|
</VCard>
|
||||||
@@ -959,8 +1187,8 @@ async function onSubmit() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Step 4: Beschikbaarheid -->
|
<!-- Beschikbaarheid -->
|
||||||
<div v-show="currentStep === 4">
|
<div v-show="currentStepKind === 'availability'">
|
||||||
<p
|
<p
|
||||||
v-if="registrationData.time_slots.length === 0"
|
v-if="registrationData.time_slots.length === 0"
|
||||||
class="text-body-1 text-medium-emphasis"
|
class="text-body-1 text-medium-emphasis"
|
||||||
|
|||||||
@@ -8,18 +8,4 @@ export const step1Schema = z.object({
|
|||||||
phone: z.string().max(50).optional().or(z.literal('')),
|
phone: z.string().max(50).optional().or(z.literal('')),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const step2Schema = z.object({
|
|
||||||
tshirt_size: z.enum(['XS', 'S', 'M', 'L', 'XL', 'XXL', 'XXXL']).optional().or(z.literal('')),
|
|
||||||
first_aid: z.boolean().default(false),
|
|
||||||
allergies: z.string().max(500).optional().or(z.literal('')),
|
|
||||||
driving_licence: z.boolean().default(false),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const step3Schema = z.object({
|
|
||||||
motivation: z.string().max(1000).optional().or(z.literal('')),
|
|
||||||
motivation_other: z.string().max(500).optional().or(z.literal('')),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const fullRegistrationSchema = step1Schema
|
export const fullRegistrationSchema = step1Schema
|
||||||
.merge(step2Schema)
|
|
||||||
.merge(step3Schema)
|
|
||||||
|
|||||||
@@ -1,3 +1,33 @@
|
|||||||
|
export type RegistrationFieldType =
|
||||||
|
| 'text'
|
||||||
|
| 'textarea'
|
||||||
|
| 'select'
|
||||||
|
| 'multiselect'
|
||||||
|
| 'checkbox'
|
||||||
|
| 'radio'
|
||||||
|
| 'boolean'
|
||||||
|
| 'number'
|
||||||
|
| 'tag_picker'
|
||||||
|
|
||||||
|
export interface RegistrationTagOption {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
category: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegistrationField {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
slug: string
|
||||||
|
field_type: RegistrationFieldType
|
||||||
|
options: string[] | null
|
||||||
|
tag_category: string | null
|
||||||
|
is_required: boolean
|
||||||
|
section: string | null
|
||||||
|
help_text: string | null
|
||||||
|
available_tags?: RegistrationTagOption[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface EventRegistrationData {
|
export interface EventRegistrationData {
|
||||||
event: {
|
event: {
|
||||||
id: string
|
id: string
|
||||||
@@ -8,9 +38,12 @@ export interface EventRegistrationData {
|
|||||||
registration_banner_url: string | null
|
registration_banner_url: string | null
|
||||||
registration_welcome_text: string | null
|
registration_welcome_text: string | null
|
||||||
registration_logo_url: string | null
|
registration_logo_url: string | null
|
||||||
|
registration_show_section_preferences: boolean
|
||||||
|
registration_show_availability: boolean
|
||||||
}
|
}
|
||||||
sections: SectionOption[]
|
sections: SectionOption[]
|
||||||
time_slots: TimeSlotOption[]
|
time_slots: TimeSlotOption[]
|
||||||
|
registration_fields: RegistrationField[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SectionOption {
|
export interface SectionOption {
|
||||||
@@ -31,7 +64,7 @@ export interface TimeSlotOption {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SectionPreference {
|
export interface SectionPreference {
|
||||||
section_name: string
|
festival_section_id: string
|
||||||
priority: number
|
priority: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,22 +74,12 @@ export interface VolunteerAvailability {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface VolunteerRegistrationForm {
|
export interface VolunteerRegistrationForm {
|
||||||
// Step 1
|
|
||||||
first_name: string
|
first_name: string
|
||||||
last_name: string
|
last_name: string
|
||||||
date_of_birth: string
|
date_of_birth: string
|
||||||
email: string
|
email: string
|
||||||
phone: string
|
phone: string
|
||||||
// Step 2
|
field_values?: Record<string, unknown>
|
||||||
tshirt_size: string
|
section_preferences?: SectionPreference[]
|
||||||
first_aid: boolean
|
|
||||||
allergies: string
|
|
||||||
driving_licence: boolean
|
|
||||||
// Step 3
|
|
||||||
motivation: string
|
|
||||||
motivation_other: string
|
|
||||||
// Step 4
|
|
||||||
section_preferences: SectionPreference[]
|
|
||||||
// Step 5
|
|
||||||
availabilities: VolunteerAvailability[]
|
availabilities: VolunteerAvailability[]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user